Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/Aspire.Cli/Commands/DestroyCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions src/Aspire.Cli/Resources/DestroyCommandStrings.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/Aspire.Cli/Resources/DestroyCommandStrings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@
<data name="Description" xml:space="preserve">
<value>Destroy a previously deployed AppHost environment (Preview)</value>
</data>
<data name="NonInteractiveRequiresYes" xml:space="preserve">
<value>The destroy command requires --yes when the --non-interactive option is specified.</value>
</data>
<data name="OutputPathArgumentDescription" xml:space="preserve">
<value>The output path containing the deployment artifacts to destroy</value>
</data>
Expand Down
5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.cs.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.de.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.es.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.fr.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.it.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ja.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ko.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pl.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ru.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.tr.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

158 changes: 155 additions & 3 deletions tests/Aspire.Cli.Tests/Commands/DestroyCommandTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<RootCommand>();

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);

Expand Down Expand Up @@ -167,7 +211,7 @@ public async Task DestroyCommandForwardsYesFlag()
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();

var result = command.Parse("destroy --yes");
var result = command.Parse(commandLine);
var exitCode = await result.InvokeAsync().DefaultTimeout();

Assert.Equal(0, exitCode);
Expand Down Expand Up @@ -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<IInteractionService>();
return new TestDeployCommandPrompter(interactionService);
};
});

using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();

var result = command.Parse("destroy --yes");
var exitCode = await result.InvokeAsync().DefaultTimeout();

Assert.Equal(ExitCodeConstants.FailedToBuildArtifacts, exitCode);

static async IAsyncEnumerable<PublishingActivity> 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
}
};
}
}
}
Loading