diff --git a/src/Aspire.Cli/Commands/DestroyCommand.cs b/src/Aspire.Cli/Commands/DestroyCommand.cs index 8169a4315a1..376aa1a59c1 100644 --- a/src/Aspire.Cli/Commands/DestroyCommand.cs +++ b/src/Aspire.Cli/Commands/DestroyCommand.cs @@ -29,6 +29,16 @@ public DestroyCommand(IDotNetCliRunner runner, IInteractionService interactionSe Description = DestroyCommandStrings.YesOptionDescription }; Options.Add(_yesOption); + + Validators.Add(result => + { + var nonInteractive = result.GetValue(RootCommand.NonInteractiveOption); + var yes = result.GetValue(_yesOption); + if (nonInteractive && !yes) + { + result.AddError(DestroyCommandStrings.NonInteractiveRequiresYes); + } + }); } protected override string OperationCompletedPrefix => DestroyCommandStrings.OperationCompletedPrefix; diff --git a/src/Aspire.Cli/Resources/DestroyCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/DestroyCommandStrings.Designer.cs index 2d8ae6319d4..5c2af895f41 100644 --- a/src/Aspire.Cli/Resources/DestroyCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/DestroyCommandStrings.Designer.cs @@ -77,6 +77,15 @@ public static string Description { return ResourceManager.GetString("Description", resourceCulture); } } + + /// + /// Looks up a localized string similar to The destroy command requires --yes when the --non-interactive option is specified.. + /// + public static string NonInteractiveRequiresYes { + get { + return ResourceManager.GetString("NonInteractiveRequiresYes", resourceCulture); + } + } /// /// Looks up a localized string similar to The output path containing the deployment artifacts to destroy. diff --git a/src/Aspire.Cli/Resources/DestroyCommandStrings.resx b/src/Aspire.Cli/Resources/DestroyCommandStrings.resx index c98f9d4bbbe..bee9ab48370 100644 --- a/src/Aspire.Cli/Resources/DestroyCommandStrings.resx +++ b/src/Aspire.Cli/Resources/DestroyCommandStrings.resx @@ -120,6 +120,9 @@ Destroy a previously deployed AppHost environment (Preview) + + The destroy command requires --yes when the --non-interactive option is specified. + The output path containing the deployment artifacts to destroy diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.cs.xlf index 9b1b2aaf2c1..df10f4b3f19 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.cs.xlf @@ -12,6 +12,11 @@ The destroy operation was canceled. + + The destroy command requires --yes when the --non-interactive option is specified. + The destroy command requires --yes when the --non-interactive option is specified. + + Destroy completed Destroy completed diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.de.xlf index 90aec8b6558..ccb65be3651 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.de.xlf @@ -12,6 +12,11 @@ The destroy operation was canceled. + + The destroy command requires --yes when the --non-interactive option is specified. + The destroy command requires --yes when the --non-interactive option is specified. + + Destroy completed Destroy completed diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.es.xlf index cbfac6dbc82..14c8a539caf 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.es.xlf @@ -12,6 +12,11 @@ The destroy operation was canceled. + + The destroy command requires --yes when the --non-interactive option is specified. + The destroy command requires --yes when the --non-interactive option is specified. + + Destroy completed Destroy completed diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.fr.xlf index beb5d05a030..4bb3522c84a 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.fr.xlf @@ -12,6 +12,11 @@ The destroy operation was canceled. + + The destroy command requires --yes when the --non-interactive option is specified. + The destroy command requires --yes when the --non-interactive option is specified. + + Destroy completed Destroy completed diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.it.xlf index c3401bc54d4..698a37bae1f 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.it.xlf @@ -12,6 +12,11 @@ The destroy operation was canceled. + + The destroy command requires --yes when the --non-interactive option is specified. + The destroy command requires --yes when the --non-interactive option is specified. + + Destroy completed Destroy completed diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ja.xlf index 69f47e07da9..1d8a89de91d 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ja.xlf @@ -12,6 +12,11 @@ The destroy operation was canceled. + + The destroy command requires --yes when the --non-interactive option is specified. + The destroy command requires --yes when the --non-interactive option is specified. + + Destroy completed Destroy completed diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ko.xlf index 9693e4695e9..7c64596959e 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ko.xlf @@ -12,6 +12,11 @@ The destroy operation was canceled. + + The destroy command requires --yes when the --non-interactive option is specified. + The destroy command requires --yes when the --non-interactive option is specified. + + Destroy completed Destroy completed diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pl.xlf index 8b95acaff7e..5d9ab5de0de 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pl.xlf @@ -12,6 +12,11 @@ The destroy operation was canceled. + + The destroy command requires --yes when the --non-interactive option is specified. + The destroy command requires --yes when the --non-interactive option is specified. + + Destroy completed Destroy completed diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pt-BR.xlf index 2fd39a6800d..8945e26a909 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pt-BR.xlf @@ -12,6 +12,11 @@ The destroy operation was canceled. + + The destroy command requires --yes when the --non-interactive option is specified. + The destroy command requires --yes when the --non-interactive option is specified. + + Destroy completed Destroy completed diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ru.xlf index 3fe68554b97..d19e2575d55 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ru.xlf @@ -12,6 +12,11 @@ The destroy operation was canceled. + + The destroy command requires --yes when the --non-interactive option is specified. + The destroy command requires --yes when the --non-interactive option is specified. + + Destroy completed Destroy completed diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.tr.xlf index 68a10828607..0bd08dffe4f 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.tr.xlf @@ -12,6 +12,11 @@ The destroy operation was canceled. + + The destroy command requires --yes when the --non-interactive option is specified. + The destroy command requires --yes when the --non-interactive option is specified. + + Destroy completed Destroy completed diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hans.xlf index dbc09d2bc57..372bdb4b285 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hans.xlf @@ -12,6 +12,11 @@ The destroy operation was canceled. + + The destroy command requires --yes when the --non-interactive option is specified. + The destroy command requires --yes when the --non-interactive option is specified. + + Destroy completed Destroy completed diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hant.xlf index c04c4da55a9..6757c480f1d 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hant.xlf @@ -12,6 +12,11 @@ The destroy operation was canceled. + + The destroy command requires --yes when the --non-interactive option is specified. + The destroy command requires --yes when the --non-interactive option is specified. + + Destroy completed Destroy completed diff --git a/tests/Aspire.Cli.Tests/Commands/DestroyCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DestroyCommandTests.cs index a435e5b6378..327e9697678 100644 --- a/tests/Aspire.Cli.Tests/Commands/DestroyCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DestroyCommandTests.cs @@ -1,8 +1,11 @@ // 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.Backchannel; using Aspire.Cli.Commands; using Aspire.Cli.Interaction; +using Aspire.Cli.Resources; using Aspire.Cli.Tests.Utils; using Aspire.Cli.Tests.TestServices; using Microsoft.Extensions.DependencyInjection; @@ -116,8 +119,49 @@ public async Task DestroyCommandPassesCorrectStepArgument() Assert.Equal(0, exitCode); } - [Fact] - public async Task DestroyCommandForwardsYesFlag() + [Theory] + [InlineData("destroy --non-interactive")] + [InlineData("--non-interactive destroy")] + public async Task DestroyCommandFailsFastWhenNonInteractiveWithoutYes(string commandLine) + { + using var tempRepo = TemporaryWorkspace.Create(outputHelper); + var appHostStarted = false; + + var services = CliTestHelper.CreateServiceCollection(tempRepo, outputHelper, options => + { + options.ProjectLocatorFactory = (sp) => new TestProjectLocator(); + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner + { + RunAsyncCallback = (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) => + { + appHostStarted = true; + return Task.FromResult(0); + } + }; + + return runner; + }; + }); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + + var result = command.Parse(commandLine); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + Assert.Contains(result.Errors, error => string.Equals(error.Message, DestroyCommandStrings.NonInteractiveRequiresYes, StringComparison.Ordinal)); + Assert.False(appHostStarted); + } + + [Theory] + [InlineData("destroy --yes")] + [InlineData("destroy --non-interactive --yes")] + [InlineData("--non-interactive destroy --yes")] + public async Task DestroyCommandForwardsYesFlag(string commandLine) { using var tempRepo = TemporaryWorkspace.Create(outputHelper); @@ -167,7 +211,7 @@ public async Task DestroyCommandForwardsYesFlag() using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("destroy --yes"); + var result = command.Parse(commandLine); var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); @@ -230,4 +274,112 @@ public async Task DestroyCommandIncludesOutputPathWhenSpecified() Assert.Equal(0, exitCode); } + + [Fact] + public async Task DestroyCommandReturnsNonZeroExitCodeWhenDestroyActivitiesFail() + { + using var tempRepo = TemporaryWorkspace.Create(outputHelper); + + var services = CliTestHelper.CreateServiceCollection(tempRepo, outputHelper, options => + { + options.ProjectLocatorFactory = (sp) => new TestProjectLocator(); + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner + { + BuildAsyncCallback = (projectFile, noRestore, options, cancellationToken) => 0, + + GetAppHostInformationAsyncCallback = (projectFile, options, cancellationToken) => + { + return (0, true, VersionHelper.GetDefaultTemplateVersion()); + }, + + RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) => + { + var destroyCompleted = new TaskCompletionSource(); + var backchannel = new TestAppHostBackchannel + { + RequestStopAsyncCalled = destroyCompleted, + GetPublishingActivitiesAsyncCallback = GetFailedDestroyActivities + }; + backchannelCompletionSource?.SetResult(backchannel); + await destroyCompleted.Task.DefaultTimeout(); + return 0; + } + }; + + return runner; + }; + + options.PublishCommandPrompterFactory = (sp) => + { + var interactionService = sp.GetRequiredService(); + return new TestDeployCommandPrompter(interactionService); + }; + }); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + + var result = command.Parse("destroy --yes"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.FailedToBuildArtifacts, exitCode); + + static async IAsyncEnumerable GetFailedDestroyActivities([EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + + yield return new PublishingActivity + { + Type = PublishingActivityTypes.Step, + Data = new PublishingActivityData + { + Id = "destroy-step", + StatusText = "Destroying resources", + CompletionState = CompletionStates.InProgress, + StepId = null + } + }; + + yield return new PublishingActivity + { + Type = PublishingActivityTypes.Task, + Data = new PublishingActivityData + { + Id = "destroy-compose", + StatusText = "Stopping containers", + CompletionState = CompletionStates.InProgress, + StepId = "destroy-step" + } + }; + + yield return new PublishingActivity + { + Type = PublishingActivityTypes.Task, + Data = new PublishingActivityData + { + Id = "destroy-compose", + StatusText = "Stopping containers failed", + CompletionMessage = "Failed to stop containers", + CompletionState = CompletionStates.CompletedWithError, + StepId = "destroy-step" + } + }; + + // Simulate publish complete with error + yield return new PublishingActivity + { + Type = PublishingActivityTypes.PublishComplete, + Data = new PublishingActivityData + { + Id = "publish-complete", + StatusText = "Deployment completed with errors", + CompletionState = CompletionStates.CompletedWithError, + StepId = null + } + }; + } + } }