diff --git a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs index 5043782c6a0..5e220a441ea 100644 --- a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs +++ b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs @@ -151,6 +151,15 @@ public async Task ExecuteAsyncCore(ResourceViewModel resource, CommandViewModel toastParameters.Intent = ToastIntent.Success; toastParameters.Icon = GetIntentIcon(ToastIntent.Success); } + else if (response.Kind == ResourceCommandResponseKind.Cancelled) + { + // For cancelled commands, just close the existing toast and don't show any success or error message + if (!toastClosed) + { + toastService.CloseToast(toastParameters.Id); + } + return; + } else { toastParameters.Title = string.Format(CultureInfo.InvariantCulture, loc[nameof(Dashboard.Resources.Resources.ResourceCommandFailed)], messageResourceName, command.GetDisplayName(commandsLoc)); diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs index fae81cce73a..5113048517f 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs @@ -130,6 +130,11 @@ public static class CommandResults /// An optional error message. public static ExecuteCommandResult Failure(string? errorMessage = null) => new() { Success = false, ErrorMessage = errorMessage }; + /// + /// Produces a canceled result. + /// + public static ExecuteCommandResult Canceled() => new() { Success = false, Canceled = true }; + /// /// Produces an unsuccessful result from an . is used as the error message. /// @@ -147,6 +152,11 @@ public sealed class ExecuteCommandResult /// public required bool Success { get; init; } + /// + /// A flag that indicates whether the command was canceled by the user. + /// + public bool Canceled { get; init; } + /// /// An optional error message that can be set when the command is unsuccessful. /// diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs index 0e21dacbf8d..416a878e18d 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs @@ -75,24 +75,37 @@ public async Task ExecuteCommandAsync(IResource resource, tasks.Add(ExecuteCommandCoreAsync(name, resource, commandName, cancellationToken)); } - // Check for failures. + // Check for failures and cancellations. var results = await Task.WhenAll(tasks).ConfigureAwait(false); var failures = new List<(string resourceId, ExecuteCommandResult result)>(); + var cancellations = new List<(string resourceId, ExecuteCommandResult result)>(); for (var i = 0; i < results.Length; i++) { if (!results[i].Success) { - failures.Add((names[i], results[i])); + if (results[i].Canceled) + { + cancellations.Add((names[i], results[i])); + } + else + { + failures.Add((names[i], results[i])); + } } } - if (failures.Count == 0) + if (failures.Count == 0 && cancellations.Count == 0) { return new ExecuteCommandResult { Success = true }; } + else if (failures.Count == 0 && cancellations.Count > 0) + { + // All non-successful commands were cancelled + return new ExecuteCommandResult { Success = false, Canceled = true }; + } else { - // Aggregate error results together. + // There were actual failures (possibly with some cancellations) var errorMessage = $"{failures.Count} command executions failed."; errorMessage += Environment.NewLine + string.Join(Environment.NewLine, failures.Select(f => $"Resource '{f.resourceId}' failed with error message: {f.result.ErrorMessage}")); @@ -128,12 +141,22 @@ internal async Task ExecuteCommandCoreAsync(string resourc logger.LogInformation("Successfully executed command '{CommandName}'.", commandName); return result; } + else if (result.Canceled) + { + logger.LogDebug("Command '{CommandName}' was canceled.", commandName); + return result; + } else { logger.LogInformation("Failure executing command '{CommandName}'. Error message: {ErrorMessage}", commandName, result.ErrorMessage); return result; } } + catch (OperationCanceledException) + { + logger.LogDebug("Command '{CommandName}' was canceled.", commandName); + return CommandResults.Canceled(); + } catch (Exception ex) { logger.LogError(ex, "Error executing command '{CommandName}'.", commandName); diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs index 10cd9da9067..82721e80162 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs @@ -99,6 +99,10 @@ public void Dispose() try { var result = await _resourceCommandService.ExecuteCommandAsync(resourceId, type, cancellationToken).ConfigureAwait(false); + if (result.Canceled) + { + return (ExecuteCommandResultType.Canceled, result.ErrorMessage); + } return (result.Success ? ExecuteCommandResultType.Success : ExecuteCommandResultType.Failure, result.ErrorMessage); } catch diff --git a/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs b/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs index c97e0faae70..32fd1753e7f 100644 --- a/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs @@ -188,6 +188,138 @@ 2 command executions failed. """, result.ErrorMessage); } + [Fact] + public async Task ExecuteCommandAsync_Canceled_Success() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + + var custom = builder.AddResource(new CustomResource("myResource")); + custom.WithCommand(name: "mycommand", + displayName: "My command", + executeCommand: e => + { + return Task.FromResult(CommandResults.Canceled()); + }); + + var app = builder.Build(); + await app.StartAsync(); + + // Act + var result = await app.ResourceCommands.ExecuteCommandAsync(custom.Resource, "mycommand"); + + // Assert + Assert.False(result.Success); + Assert.True(result.Canceled); + Assert.Null(result.ErrorMessage); + } + + [Fact] + public async Task ExecuteCommandAsync_HasReplicas_Canceled_CalledPerReplica() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + + var resourceBuilder = builder.AddProject("servicea") + .WithReplicas(2) + .WithCommand(name: "mycommand", + displayName: "My command", + executeCommand: e => + { + return Task.FromResult(CommandResults.Canceled()); + }); + + // Act + var app = builder.Build(); + await app.StartAsync(); + await app.ResourceNotifications.WaitForResourceHealthyAsync("servicea").DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + + var result = await app.ResourceCommands.ExecuteCommandAsync(resourceBuilder.Resource, "mycommand"); + + // Assert + Assert.False(result.Success); + Assert.True(result.Canceled); + Assert.Null(result.ErrorMessage); + } + + [Fact] + public async Task ExecuteCommandAsync_HasReplicas_MixedFailureAndCanceled_OnlyFailuresInErrorMessage() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + + var callCount = 0; + var resourceBuilder = builder.AddProject("servicea") + .WithReplicas(3) + .WithCommand(name: "mycommand", + displayName: "My command", + executeCommand: e => + { + var count = Interlocked.Increment(ref callCount); + return Task.FromResult(count switch + { + 1 => CommandResults.Failure("Failure!"), + 2 => CommandResults.Canceled(), + _ => CommandResults.Success() + }); + }); + + // Act + var app = builder.Build(); + await app.StartAsync(); + await app.ResourceNotifications.WaitForResourceHealthyAsync("servicea").DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + + var result = await app.ResourceCommands.ExecuteCommandAsync(resourceBuilder.Resource, "mycommand"); + + // Assert + Assert.False(result.Success); + Assert.False(result.Canceled); // Should not be canceled since there was at least one failure + + var resourceNames = resourceBuilder.Resource.GetResolvedResourceNames(); + Assert.Equal($""" + 1 command executions failed. + Resource '{resourceNames[0]}' failed with error message: Failure! + """, result.ErrorMessage); + } + + [Fact] + public void CommandResults_Canceled_ProducesCorrectResult() + { + // Act + var result = CommandResults.Canceled(); + + // Assert + Assert.False(result.Success); + Assert.True(result.Canceled); + Assert.Null(result.ErrorMessage); + } + + [Fact] + public async Task ExecuteCommandAsync_OperationCanceledException_Canceled() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + + var custom = builder.AddResource(new CustomResource("myResource")); + custom.WithCommand(name: "mycommand", + displayName: "My command", + executeCommand: e => + { + throw new OperationCanceledException("Command was canceled"); + }); + + var app = builder.Build(); + await app.StartAsync(); + + // Act + var result = await app.ResourceCommands.ExecuteCommandAsync(custom.Resource, "mycommand"); + + // Assert + Assert.False(result.Success); + Assert.True(result.Canceled); + Assert.Null(result.ErrorMessage); + } + private sealed class CustomResource(string name) : Resource(name), IResourceWithEndpoints, IResourceWithWaitSupport {