From e6edfa682b351847670cf99b4f9bd7c7420bf21e Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 09:02:37 -0700 Subject: [PATCH 01/35] Add aspire destroy command for tearing down deployed environments Implements #13013 - adds a top-level 'aspire destroy' command that tears down previously deployed Aspire environments. The command follows the same pipeline architecture as 'aspire deploy' and 'aspire publish'. Changes: - Add WellKnownPipelineSteps.Destroy and DestroyPrereq aggregation steps - Add DestroyCommand CLI command with --yes flag to skip confirmation - Add destroy-prereq step with interactive confirmation prompt - Wire Docker Compose's existing docker-compose-down step to destroy - Wire Kubernetes Helm's existing helm-uninstall step to destroy - Add Azure resource group deletion via ARM SDK for ACA/App Service - Add IResourceGroupResource.DeleteAsync to provisioning abstractions - Add PipelineOptions.Yes for forwarding --yes flag to AppHost - Update pipeline step count test and accept diagnostics snapshots Validated with pipeline tests (65), Docker Compose tests (85), Kubernetes tests (88), and Azure deployer tests (28) all passing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/DestroyCommand.cs | 82 +++++++++++ src/Aspire.Cli/Commands/RootCommand.cs | 2 + src/Aspire.Cli/Program.cs | 1 + .../DestroyCommandStrings.Designer.cs | 117 +++++++++++++++ .../Resources/DestroyCommandStrings.resx | 138 ++++++++++++++++++ .../xlf/DestroyCommandStrings.cs.xlf | 37 +++++ .../xlf/DestroyCommandStrings.de.xlf | 37 +++++ .../xlf/DestroyCommandStrings.es.xlf | 37 +++++ .../xlf/DestroyCommandStrings.fr.xlf | 37 +++++ .../xlf/DestroyCommandStrings.it.xlf | 37 +++++ .../xlf/DestroyCommandStrings.ja.xlf | 37 +++++ .../xlf/DestroyCommandStrings.ko.xlf | 37 +++++ .../xlf/DestroyCommandStrings.pl.xlf | 37 +++++ .../xlf/DestroyCommandStrings.pt-BR.xlf | 37 +++++ .../xlf/DestroyCommandStrings.ru.xlf | 37 +++++ .../xlf/DestroyCommandStrings.tr.xlf | 37 +++++ .../xlf/DestroyCommandStrings.zh-Hans.xlf | 37 +++++ .../xlf/DestroyCommandStrings.zh-Hant.xlf | 37 +++++ .../AzureEnvironmentResource.cs | 82 +++++++++++ .../Internal/DefaultResourceGroupResource.cs | 6 + .../Internal/IProvisioningServices.cs | 5 + .../DockerComposeEnvironmentResource.cs | 2 + .../Deployment/HelmDeploymentEngine.cs | 2 + .../DistributedApplicationBuilder.cs | 2 + .../DistributedApplicationPipeline.cs | 48 ++++++ .../Pipelines/PipelineOptions.cs | 5 + .../Pipelines/WellKnownPipelineSteps.cs | 11 ++ .../ProvisioningTestHelpers.cs | 5 + ..._DoesNotHang_step=diagnostics.verified.txt | 53 ++++++- ...nments_Works_step=diagnostics.verified.txt | 53 ++++++- ...ts_CreatesCorrectDependencies.verified.txt | 51 ++++++- ...on_CreatesCorrectDependencies.verified.txt | 53 ++++++- .../DistributedApplicationPipelineTests.cs | 4 +- 33 files changed, 1171 insertions(+), 32 deletions(-) create mode 100644 src/Aspire.Cli/Commands/DestroyCommand.cs create mode 100644 src/Aspire.Cli/Resources/DestroyCommandStrings.Designer.cs create mode 100644 src/Aspire.Cli/Resources/DestroyCommandStrings.resx create mode 100644 src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.cs.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.de.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.es.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.fr.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.it.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ja.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ko.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pl.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pt-BR.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ru.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.tr.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hans.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hant.xlf diff --git a/src/Aspire.Cli/Commands/DestroyCommand.cs b/src/Aspire.Cli/Commands/DestroyCommand.cs new file mode 100644 index 00000000000..76d76b3d6f1 --- /dev/null +++ b/src/Aspire.Cli/Commands/DestroyCommand.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.CommandLine; +using Aspire.Cli.Configuration; +using Aspire.Cli.DotNet; +using Aspire.Cli.Interaction; +using Aspire.Cli.Projects; +using Aspire.Cli.Resources; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Spectre.Console; + +namespace Aspire.Cli.Commands; + +internal sealed class DestroyCommand : PipelineCommandBase +{ + internal override HelpGroup HelpGroup => HelpGroup.Deployment; + + private readonly Option _yesOption; + + public DestroyCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, AspireCliTelemetry telemetry, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, IConfiguration configuration, ILogger logger, IAnsiConsole ansiConsole) + : base("destroy", DestroyCommandStrings.Description, runner, interactionService, projectLocator, telemetry, features, updateNotifier, executionContext, hostEnvironment, projectFactory, configuration, logger, ansiConsole) + { + _yesOption = new Option("--yes", "-y") + { + Description = DestroyCommandStrings.YesOptionDescription + }; + Options.Add(_yesOption); + } + + protected override string OperationCompletedPrefix => DestroyCommandStrings.OperationCompletedPrefix; + protected override string OperationFailedPrefix => DestroyCommandStrings.OperationFailedPrefix; + protected override string GetOutputPathDescription() => DestroyCommandStrings.OutputPathArgumentDescription; + + protected override Task GetRunArgumentsAsync(string? fullyQualifiedOutputPath, string[] unmatchedTokens, ParseResult parseResult, CancellationToken cancellationToken) + { + var baseArgs = new List { "--operation", "publish", "--step", "destroy" }; + + if (fullyQualifiedOutputPath != null) + { + baseArgs.AddRange(["--output-path", fullyQualifiedOutputPath]); + } + + var yes = parseResult.GetValue(_yesOption); + if (yes) + { + baseArgs.AddRange(["--yes", "true"]); + } + + var logLevel = parseResult.GetValue(s_logLevelOption); + if (!string.IsNullOrEmpty(logLevel)) + { + baseArgs.AddRange(["--log-level", logLevel!]); + } + + var includeExceptionDetails = parseResult.GetValue(s_includeExceptionDetailsOption); + if (includeExceptionDetails) + { + baseArgs.AddRange(["--include-exception-details", "true"]); + } + + var environment = parseResult.GetValue(s_environmentOption); + if (!string.IsNullOrEmpty(environment)) + { + baseArgs.AddRange(["--environment", environment!]); + } + + baseArgs.AddRange(unmatchedTokens); + + return Task.FromResult([.. baseArgs]); + } + + protected override string GetCanceledMessage() => DestroyCommandStrings.DestroyCanceled; + + protected override string GetProgressMessage(ParseResult parseResult) + { + return "Executing step destroy"; + } +} diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index 7a0c06743f8..5ffec201cbc 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -120,6 +120,7 @@ public RootCommand( AddCommand addCommand, PublishCommand publishCommand, DeployCommand deployCommand, + DestroyCommand destroyCommand, DoCommand doCommand, ConfigCommand configCommand, CacheCommand cacheCommand, @@ -215,6 +216,7 @@ public RootCommand( Subcommands.Add(certificatesCommand); Subcommands.Add(doctorCommand); Subcommands.Add(deployCommand); + Subcommands.Add(destroyCommand); Subcommands.Add(doCommand); Subcommands.Add(updateCommand); Subcommands.Add(extensionInternalCommand); diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 0f544c24a57..fc098c36fc2 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -471,6 +471,7 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu 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/DestroyCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/DestroyCommandStrings.Designer.cs new file mode 100644 index 00000000000..224986475b6 --- /dev/null +++ b/src/Aspire.Cli/Resources/DestroyCommandStrings.Designer.cs @@ -0,0 +1,117 @@ +//------------------------------------------------------------------------------ +// +// 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()] + public class DestroyCommandStrings { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal DestroyCommandStrings() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public 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.DestroyCommandStrings", typeof(DestroyCommandStrings).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)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to The destroy operation was canceled.. + /// + public static string DestroyCanceled { + get { + return ResourceManager.GetString("DestroyCanceled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Destroy a previously deployed AppHost environment (Preview). + /// + public static string Description { + get { + return ResourceManager.GetString("Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The output path containing the deployment artifacts to destroy. + /// + public static string OutputPathArgumentDescription { + get { + return ResourceManager.GetString("OutputPathArgumentDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to DESTROY COMPLETED. + /// + public static string OperationCompletedPrefix { + get { + return ResourceManager.GetString("OperationCompletedPrefix", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to DESTROY FAILED. + /// + public static string OperationFailedPrefix { + get { + return ResourceManager.GetString("OperationFailedPrefix", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Skip the confirmation prompt and proceed with the destroy operation. + /// + public static string YesOptionDescription { + get { + return ResourceManager.GetString("YesOptionDescription", resourceCulture); + } + } + } +} diff --git a/src/Aspire.Cli/Resources/DestroyCommandStrings.resx b/src/Aspire.Cli/Resources/DestroyCommandStrings.resx new file mode 100644 index 00000000000..5be88ea6f8f --- /dev/null +++ b/src/Aspire.Cli/Resources/DestroyCommandStrings.resx @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Destroy a previously deployed AppHost environment (Preview) + + + The output path containing the deployment artifacts to destroy + + + The destroy operation was canceled. + + + DESTROY COMPLETED + + + DESTROY FAILED + + + Skip the confirmation prompt and proceed with the destroy operation + + diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.cs.xlf new file mode 100644 index 00000000000..2a491503ece --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.cs.xlf @@ -0,0 +1,37 @@ + + + + + + Destroy a previously deployed AppHost environment (Preview) + Destroy a previously deployed AppHost environment (Preview) + + + + The destroy operation was canceled. + The destroy operation was canceled. + + + + DESTROY COMPLETED + DESTROY COMPLETED + + + + DESTROY FAILED + DESTROY FAILED + + + + The output path containing the deployment artifacts to destroy + The output path containing the deployment artifacts to destroy + + + + Skip the confirmation prompt and proceed with the destroy operation + Skip the confirmation prompt and proceed with the destroy operation + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.de.xlf new file mode 100644 index 00000000000..6001569f0f3 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.de.xlf @@ -0,0 +1,37 @@ + + + + + + Destroy a previously deployed AppHost environment (Preview) + Destroy a previously deployed AppHost environment (Preview) + + + + The destroy operation was canceled. + The destroy operation was canceled. + + + + DESTROY COMPLETED + DESTROY COMPLETED + + + + DESTROY FAILED + DESTROY FAILED + + + + The output path containing the deployment artifacts to destroy + The output path containing the deployment artifacts to destroy + + + + Skip the confirmation prompt and proceed with the destroy operation + Skip the confirmation prompt and proceed with the destroy operation + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.es.xlf new file mode 100644 index 00000000000..8f53b6340f9 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.es.xlf @@ -0,0 +1,37 @@ + + + + + + Destroy a previously deployed AppHost environment (Preview) + Destroy a previously deployed AppHost environment (Preview) + + + + The destroy operation was canceled. + The destroy operation was canceled. + + + + DESTROY COMPLETED + DESTROY COMPLETED + + + + DESTROY FAILED + DESTROY FAILED + + + + The output path containing the deployment artifacts to destroy + The output path containing the deployment artifacts to destroy + + + + Skip the confirmation prompt and proceed with the destroy operation + Skip the confirmation prompt and proceed with the destroy operation + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.fr.xlf new file mode 100644 index 00000000000..b41fd8f994e --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.fr.xlf @@ -0,0 +1,37 @@ + + + + + + Destroy a previously deployed AppHost environment (Preview) + Destroy a previously deployed AppHost environment (Preview) + + + + The destroy operation was canceled. + The destroy operation was canceled. + + + + DESTROY COMPLETED + DESTROY COMPLETED + + + + DESTROY FAILED + DESTROY FAILED + + + + The output path containing the deployment artifacts to destroy + The output path containing the deployment artifacts to destroy + + + + Skip the confirmation prompt and proceed with the destroy operation + Skip the confirmation prompt and proceed with the destroy operation + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.it.xlf new file mode 100644 index 00000000000..26fb6ab4eeb --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.it.xlf @@ -0,0 +1,37 @@ + + + + + + Destroy a previously deployed AppHost environment (Preview) + Destroy a previously deployed AppHost environment (Preview) + + + + The destroy operation was canceled. + The destroy operation was canceled. + + + + DESTROY COMPLETED + DESTROY COMPLETED + + + + DESTROY FAILED + DESTROY FAILED + + + + The output path containing the deployment artifacts to destroy + The output path containing the deployment artifacts to destroy + + + + Skip the confirmation prompt and proceed with the destroy operation + Skip the confirmation prompt and proceed with the destroy operation + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ja.xlf new file mode 100644 index 00000000000..ba79fe1c101 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ja.xlf @@ -0,0 +1,37 @@ + + + + + + Destroy a previously deployed AppHost environment (Preview) + Destroy a previously deployed AppHost environment (Preview) + + + + The destroy operation was canceled. + The destroy operation was canceled. + + + + DESTROY COMPLETED + DESTROY COMPLETED + + + + DESTROY FAILED + DESTROY FAILED + + + + The output path containing the deployment artifacts to destroy + The output path containing the deployment artifacts to destroy + + + + Skip the confirmation prompt and proceed with the destroy operation + Skip the confirmation prompt and proceed with the destroy operation + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ko.xlf new file mode 100644 index 00000000000..791532aa167 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ko.xlf @@ -0,0 +1,37 @@ + + + + + + Destroy a previously deployed AppHost environment (Preview) + Destroy a previously deployed AppHost environment (Preview) + + + + The destroy operation was canceled. + The destroy operation was canceled. + + + + DESTROY COMPLETED + DESTROY COMPLETED + + + + DESTROY FAILED + DESTROY FAILED + + + + The output path containing the deployment artifacts to destroy + The output path containing the deployment artifacts to destroy + + + + Skip the confirmation prompt and proceed with the destroy operation + Skip the confirmation prompt and proceed with the destroy operation + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pl.xlf new file mode 100644 index 00000000000..10cbd21d114 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pl.xlf @@ -0,0 +1,37 @@ + + + + + + Destroy a previously deployed AppHost environment (Preview) + Destroy a previously deployed AppHost environment (Preview) + + + + The destroy operation was canceled. + The destroy operation was canceled. + + + + DESTROY COMPLETED + DESTROY COMPLETED + + + + DESTROY FAILED + DESTROY FAILED + + + + The output path containing the deployment artifacts to destroy + The output path containing the deployment artifacts to destroy + + + + Skip the confirmation prompt and proceed with the destroy operation + Skip the confirmation prompt and proceed with the destroy operation + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pt-BR.xlf new file mode 100644 index 00000000000..eb9500dda82 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pt-BR.xlf @@ -0,0 +1,37 @@ + + + + + + Destroy a previously deployed AppHost environment (Preview) + Destroy a previously deployed AppHost environment (Preview) + + + + The destroy operation was canceled. + The destroy operation was canceled. + + + + DESTROY COMPLETED + DESTROY COMPLETED + + + + DESTROY FAILED + DESTROY FAILED + + + + The output path containing the deployment artifacts to destroy + The output path containing the deployment artifacts to destroy + + + + Skip the confirmation prompt and proceed with the destroy operation + Skip the confirmation prompt and proceed with the destroy operation + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ru.xlf new file mode 100644 index 00000000000..368567c6ecb --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ru.xlf @@ -0,0 +1,37 @@ + + + + + + Destroy a previously deployed AppHost environment (Preview) + Destroy a previously deployed AppHost environment (Preview) + + + + The destroy operation was canceled. + The destroy operation was canceled. + + + + DESTROY COMPLETED + DESTROY COMPLETED + + + + DESTROY FAILED + DESTROY FAILED + + + + The output path containing the deployment artifacts to destroy + The output path containing the deployment artifacts to destroy + + + + Skip the confirmation prompt and proceed with the destroy operation + Skip the confirmation prompt and proceed with the destroy operation + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.tr.xlf new file mode 100644 index 00000000000..50d41bd89bd --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.tr.xlf @@ -0,0 +1,37 @@ + + + + + + Destroy a previously deployed AppHost environment (Preview) + Destroy a previously deployed AppHost environment (Preview) + + + + The destroy operation was canceled. + The destroy operation was canceled. + + + + DESTROY COMPLETED + DESTROY COMPLETED + + + + DESTROY FAILED + DESTROY FAILED + + + + The output path containing the deployment artifacts to destroy + The output path containing the deployment artifacts to destroy + + + + Skip the confirmation prompt and proceed with the destroy operation + Skip the confirmation prompt and proceed with the destroy operation + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hans.xlf new file mode 100644 index 00000000000..3cbc1a384f8 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hans.xlf @@ -0,0 +1,37 @@ + + + + + + Destroy a previously deployed AppHost environment (Preview) + Destroy a previously deployed AppHost environment (Preview) + + + + The destroy operation was canceled. + The destroy operation was canceled. + + + + DESTROY COMPLETED + DESTROY COMPLETED + + + + DESTROY FAILED + DESTROY FAILED + + + + The output path containing the deployment artifacts to destroy + The output path containing the deployment artifacts to destroy + + + + Skip the confirmation prompt and proceed with the destroy operation + Skip the confirmation prompt and proceed with the destroy operation + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hant.xlf new file mode 100644 index 00000000000..81f22522011 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hant.xlf @@ -0,0 +1,37 @@ + + + + + + Destroy a previously deployed AppHost environment (Preview) + Destroy a previously deployed AppHost environment (Preview) + + + + The destroy operation was canceled. + The destroy operation was canceled. + + + + DESTROY COMPLETED + DESTROY COMPLETED + + + + DESTROY FAILED + DESTROY FAILED + + + + The output path containing the deployment artifacts to destroy + The output path containing the deployment artifacts to destroy + + + + Skip the confirmation prompt and proceed with the destroy operation + Skip the confirmation prompt and proceed with the destroy operation + + + + + \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 9211021b1b5..0955c403cc2 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -119,6 +119,21 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet return [publishStep, validateStep, createContextStep, provisionStep]; })); + // Add destroy step for tearing down Azure resources + Annotations.Add(new PipelineStepAnnotation((factoryContext) => + { + var destroyStep = new PipelineStep + { + Name = $"destroy-azure-{Name}", + Description = $"Destroys the Azure resource group and all resources for {Name}.", + Action = ctx => DestroyAzureResourcesAsync(ctx), + RequiredBySteps = [WellKnownPipelineSteps.Destroy], + DependsOnSteps = [WellKnownPipelineSteps.DestroyPrereq] + }; + + return [destroyStep]; + })); + Annotations.Add(ManifestPublishingCallbackAnnotation.Ignore); Location = location; @@ -186,4 +201,71 @@ await context.ReportingStep.CompleteAsync( throw; } } + + private static async Task DestroyAzureResourcesAsync(PipelineStepContext context) + { + var deploymentStateManager = context.Services.GetRequiredService(); + var tokenCredentialProvider = context.Services.GetRequiredService(); + var armClientProvider = context.Services.GetRequiredService(); + + // Read deployment state to find the resource group + var azureStateSection = await deploymentStateManager.AcquireSectionAsync("Azure", context.CancellationToken).ConfigureAwait(false); + + var resourceGroupName = azureStateSection.Data["ResourceGroup"]?.ToString(); + var subscriptionId = azureStateSection.Data["SubscriptionId"]?.ToString(); + + if (string.IsNullOrEmpty(resourceGroupName) || string.IsNullOrEmpty(subscriptionId)) + { + await context.ReportingStep.CompleteAsync( + "No Azure deployment state found. Nothing to destroy.", + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); + return; + } + + var deployTask = await context.ReportingStep.CreateTaskAsync( + new MarkdownString($"Deleting Azure resource group **{resourceGroupName}**"), + context.CancellationToken).ConfigureAwait(false); + await using (deployTask.ConfigureAwait(false)) + { + try + { + var credential = tokenCredentialProvider.TokenCredential; + var armClient = armClientProvider.GetArmClient(credential, subscriptionId); + var (subscription, _) = await armClient.GetSubscriptionAndTenantAsync(context.CancellationToken).ConfigureAwait(false); + + var resourceGroups = subscription.GetResourceGroups(); + var rgResponse = await resourceGroups.GetAsync(resourceGroupName, context.CancellationToken).ConfigureAwait(false); + var resourceGroup = rgResponse.Value; + + await resourceGroup.DeleteAsync(global::Azure.WaitUntil.Started, context.CancellationToken).ConfigureAwait(false); + + // Clean up deployment state after successful destroy initiation + await deploymentStateManager.DeleteSectionAsync(azureStateSection, context.CancellationToken).ConfigureAwait(false); + + await deployTask.CompleteAsync( + new MarkdownString($"Resource group **{resourceGroupName}** deletion initiated successfully"), + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); + } + catch (global::Azure.RequestFailedException ex) when (ex.Status == 404) + { + // Resource group already deleted + await deploymentStateManager.DeleteSectionAsync(azureStateSection, context.CancellationToken).ConfigureAwait(false); + + await deployTask.CompleteAsync( + new MarkdownString($"Resource group **{resourceGroupName}** not found (already deleted)"), + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + await deployTask.CompleteAsync( + $"Failed to delete resource group '{resourceGroupName}': {ex.Message}", + CompletionState.CompletedWithError, + context.CancellationToken).ConfigureAwait(false); + throw; + } + } + } } diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultResourceGroupResource.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultResourceGroupResource.cs index 5ec8d1f7e1c..c19a26dad01 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultResourceGroupResource.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultResourceGroupResource.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 Azure; using Azure.Core; using Azure.ResourceManager.Resources; @@ -18,4 +19,9 @@ public IArmDeploymentCollection GetArmDeployments() { return new DefaultArmDeploymentCollection(resourceGroupResource.GetArmDeployments()); } + + public async Task DeleteAsync(WaitUntil waitUntil, CancellationToken cancellationToken = default) + { + await resourceGroupResource.DeleteAsync(waitUntil, cancellationToken: cancellationToken).ConfigureAwait(false); + } } diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs index 40b0b2b714b..99d17fc16e0 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs @@ -163,6 +163,11 @@ internal interface IResourceGroupResource /// Gets ARM deployments collection. /// IArmDeploymentCollection GetArmDeployments(); + + /// + /// Deletes the resource group. + /// + Task DeleteAsync(WaitUntil waitUntil, CancellationToken cancellationToken = default); } /// diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs index 44047f40ef2..1357e8c984a 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs @@ -127,6 +127,8 @@ public DockerComposeEnvironmentResource(string name) : base(name) Action = ctx => DockerComposeDownAsync(ctx), Tags = ["docker-compose-down"] }; + dockerComposeDownStep.RequiredBy(WellKnownPipelineSteps.Destroy); + dockerComposeDownStep.DependsOn(WellKnownPipelineSteps.DestroyPrereq); steps.Add(dockerComposeDownStep); return steps; diff --git a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs index af13f3930ac..8c5f2b9f05c 100644 --- a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs +++ b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs @@ -160,6 +160,8 @@ internal static Task> CreateStepsAsync( Tags = [HelmUninstallTag], Action = ctx => HelmUninstallAsync(ctx, environment) }; + helmUninstallStep.RequiredBy(WellKnownPipelineSteps.Destroy); + helmUninstallStep.DependsOn(WellKnownPipelineSteps.DestroyPrereq); steps.Add(helmUninstallStep); return Task.FromResult>(steps); diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index bceeacd410e..720c040d2b6 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -647,6 +647,8 @@ private void ConfigurePipelineOptions(DistributedApplicationOptions options) // TODO: Rename this to something related to deployment state { "--clear-cache", "Pipeline:ClearCache" }, + { "--yes", "Pipeline:Yes" }, + // DCP Publisher options, we should only process these in run mode { "--dcp-cli-path", "DcpPublisher:CliPath" }, { "--dcp-container-runtime", "DcpPublisher:ContainerRuntime" }, diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index 679b90f1cd8..4ca872c8723 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -264,6 +264,54 @@ public DistributedApplicationPipeline() DumpDependencyGraphDiagnostics(stepsToAnalyze, context); } }); + + // Add a "destroy" aggregation step for teardown operations + _steps.Add(new PipelineStep + { + Name = WellKnownPipelineSteps.Destroy, + Description = "Aggregation step for all destroy operations. All destroy steps should be required by this step.", + Action = _ => Task.CompletedTask, + }); + + _steps.Add(new PipelineStep + { + Name = WellKnownPipelineSteps.DestroyPrereq, + Description = "Prerequisite step that runs before any destroy operations. Confirms the destructive action and verifies deployment state.", + Action = async context => + { + var hostEnvironment = context.Services.GetRequiredService(); + var options = context.Services.GetRequiredService>(); + + context.Logger.LogInformation("Preparing to destroy environment '{EnvironmentName}'", hostEnvironment.EnvironmentName); + + if (!options.Value.Yes) + { + var interactionService = context.Services.GetRequiredService(); + + if (interactionService.IsAvailable) + { + var result = await interactionService.PromptNotificationAsync( + "Destroy Environment", + $"This will destroy all resources for the '{hostEnvironment.EnvironmentName}' environment. This action cannot be undone. Do you want to continue?", + new NotificationInteractionOptions + { + Intent = MessageIntent.Confirmation, + ShowSecondaryButton = true, + ShowDismiss = false, + PrimaryButtonText = "Yes, destroy", + SecondaryButtonText = "Cancel" + }, + context.CancellationToken).ConfigureAwait(false); + + if (result.Canceled || !result.Data) + { + context.Logger.LogInformation("User canceled the destroy operation."); + throw new OperationCanceledException("Destroy operation canceled by user."); + } + } + } + } + }); } public bool HasSteps => _steps.Count > 0; diff --git a/src/Aspire.Hosting/Pipelines/PipelineOptions.cs b/src/Aspire.Hosting/Pipelines/PipelineOptions.cs index 1652917c998..bacb640d808 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineOptions.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineOptions.cs @@ -32,4 +32,9 @@ public class PipelineOptions /// Gets or sets the minimum log level for pipeline execution. /// public string? LogLevel { get; set; } + + /// + /// Gets or sets a value indicating whether to skip confirmation prompts for destructive operations. + /// + public bool Yes { get; set; } } diff --git a/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs b/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs index 855271cefa1..1fa93b218b7 100644 --- a/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs +++ b/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs @@ -63,4 +63,15 @@ public static class WellKnownPipelineSteps /// The diagnostic step that dumps dependency graph information for troubleshooting. /// public const string Diagnostics = "diagnostics"; + + /// + /// Aggregation step for all destroy operations. + /// All destroy steps should be required by this step. + /// + public const string Destroy = "destroy"; + + /// + /// The prerequisite step that runs before any destroy operations. + /// + public const string DestroyPrereq = "destroy-prereq"; } diff --git a/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs b/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs index 41e2cd705b5..809f861dd94 100644 --- a/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs +++ b/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs @@ -397,6 +397,11 @@ public IArmDeploymentCollection GetArmDeployments() } return new TestArmDeploymentCollection(_deploymentOutputs!); } + + public Task DeleteAsync(WaitUntil waitUntil, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } } /// diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithAzureResourceDependencies_DoesNotHang_step=diagnostics.verified.txt b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithAzureResourceDependencies_DoesNotHang_step=diagnostics.verified.txt index 8e5e201afca..06cd6bc1af9 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithAzureResourceDependencies_DoesNotHang_step=diagnostics.verified.txt +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithAzureResourceDependencies_DoesNotHang_step=diagnostics.verified.txt @@ -5,7 +5,7 @@ PIPELINE DEPENDENCY GRAPH DIAGNOSTICS This diagnostic output shows the complete pipeline dependency graph structure. Use this to understand step relationships and troubleshoot execution issues. -Total steps defined: 28 +Total steps defined: 31 Analysis for full pipeline execution (showing all steps and their relationships) @@ -35,13 +35,16 @@ Steps with no dependencies run first, followed by steps that depend on them. 19. print-dashboard-url-env 20. deploy 21. deploy-api - 22. diagnostics - 23. publish-prereq - 24. publish-azure634f9 - 25. validate-appservice-config-env - 26. publish - 27. publish-manifest - 28. push + 22. destroy-prereq + 23. destroy-azure-azure634f9 + 24. destroy + 25. diagnostics + 26. publish-prereq + 27. publish-azure634f9 + 28. validate-appservice-config-env + 29. publish + 30. publish-manifest + 31. push DETAILED STEP ANALYSIS ====================== @@ -81,6 +84,19 @@ Step: deploy-prereq Description: Prerequisite step that runs before any deploy operations. Initializes deployment environment and manages deployment state. Dependencies: ✓ process-parameters +Step: destroy + Description: Aggregation step for all destroy operations. All destroy steps should be required by this step. + Dependencies: ✓ destroy-azure-azure634f9 + +Step: destroy-azure-azure634f9 + Description: Destroys the Azure resource group and all resources for azure634f9. + Dependencies: ✓ destroy-prereq + Resource: azure634f9 (AzureEnvironmentResource) + +Step: destroy-prereq + Description: Prerequisite step that runs before any destroy operations. Confirms the destructive action and verifies deployment state. + Dependencies: none + Step: diagnostics Description: Dumps dependency graph information for troubleshooting pipeline execution. Dependencies: none @@ -272,6 +288,27 @@ If targeting 'deploy-prereq': [0] process-parameters [1] deploy-prereq +If targeting 'destroy': + Direct dependencies: destroy-azure-azure634f9 + Total steps: 3 + Execution order: + [0] destroy-prereq + [1] destroy-azure-azure634f9 + [2] destroy + +If targeting 'destroy-azure-azure634f9': + Direct dependencies: destroy-prereq + Total steps: 2 + Execution order: + [0] destroy-prereq + [1] destroy-azure-azure634f9 + +If targeting 'destroy-prereq': + Direct dependencies: none + Total steps: 1 + Execution order: + [0] destroy-prereq + If targeting 'diagnostics': Direct dependencies: none Total steps: 1 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..425a346658d 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 @@ -5,7 +5,7 @@ PIPELINE DEPENDENCY GRAPH DIAGNOSTICS This diagnostic output shows the complete pipeline dependency graph structure. Use this to understand step relationships and troubleshoot execution issues. -Total steps defined: 38 +Total steps defined: 41 Analysis for full pipeline execution (showing all steps and their relationships) @@ -45,13 +45,16 @@ Steps with no dependencies run first, followed by steps that depend on them. 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 + 32. destroy-prereq + 33. destroy-azure-azure634f9 + 34. destroy + 35. diagnostics + 36. publish-prereq + 37. publish-azure634f9 + 38. validate-appservice-config-aas-env + 39. publish + 40. publish-manifest + 41. push DETAILED STEP ANALYSIS ====================== @@ -108,6 +111,19 @@ Step: deploy-python-app Resource: python-app-containerapp (AzureContainerAppResource) Tags: deploy-compute +Step: destroy + Description: Aggregation step for all destroy operations. All destroy steps should be required by this step. + Dependencies: ✓ destroy-azure-azure634f9 + +Step: destroy-azure-azure634f9 + Description: Destroys the Azure resource group and all resources for azure634f9. + Dependencies: ✓ destroy-prereq + Resource: azure634f9 (AzureEnvironmentResource) + +Step: destroy-prereq + Description: Prerequisite step that runs before any destroy operations. Confirms the destructive action and verifies deployment state. + Dependencies: none + Step: diagnostics Description: Dumps dependency graph information for troubleshooting pipeline execution. Dependencies: none @@ -377,6 +393,27 @@ If targeting 'deploy-python-app': [9] print-python-app-summary [10] deploy-python-app +If targeting 'destroy': + Direct dependencies: destroy-azure-azure634f9 + Total steps: 3 + Execution order: + [0] destroy-prereq + [1] destroy-azure-azure634f9 + [2] destroy + +If targeting 'destroy-azure-azure634f9': + Direct dependencies: destroy-prereq + Total steps: 2 + Execution order: + [0] destroy-prereq + [1] destroy-azure-azure634f9 + +If targeting 'destroy-prereq': + Direct dependencies: none + Total steps: 1 + Execution order: + [0] destroy-prereq + If targeting 'diagnostics': Direct dependencies: none Total steps: 1 diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithPrivateEndpoints_CreatesCorrectDependencies.verified.txt b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithPrivateEndpoints_CreatesCorrectDependencies.verified.txt index 15493b3cdf2..64abefad9ac 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithPrivateEndpoints_CreatesCorrectDependencies.verified.txt +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithPrivateEndpoints_CreatesCorrectDependencies.verified.txt @@ -5,7 +5,7 @@ PIPELINE DEPENDENCY GRAPH DIAGNOSTICS This diagnostic output shows the complete pipeline dependency graph structure. Use this to understand step relationships and troubleshoot execution issues. -Total steps defined: 40 +Total steps defined: 43 Analysis for full pipeline execution (showing all steps and their relationships) @@ -48,12 +48,15 @@ Steps with no dependencies run first, followed by steps that depend on them. 32. print-dashboard-url-env 33. deploy 34. deploy-api - 35. diagnostics - 36. publish-prereq - 37. publish-azure634f9 - 38. publish - 39. publish-manifest - 40. push + 35. destroy-prereq + 36. destroy-azure-azure634f9 + 37. destroy + 38. diagnostics + 39. publish-prereq + 40. publish-azure634f9 + 41. publish + 42. publish-manifest + 43. push DETAILED STEP ANALYSIS ====================== @@ -93,6 +96,19 @@ Step: deploy-prereq Description: Prerequisite step that runs before any deploy operations. Initializes deployment environment and manages deployment state. Dependencies: ✓ process-parameters +Step: destroy + Description: Aggregation step for all destroy operations. All destroy steps should be required by this step. + Dependencies: ✓ destroy-azure-azure634f9 + +Step: destroy-azure-azure634f9 + Description: Destroys the Azure resource group and all resources for azure634f9. + Dependencies: ✓ destroy-prereq + Resource: azure634f9 (AzureEnvironmentResource) + +Step: destroy-prereq + Description: Prerequisite step that runs before any destroy operations. Confirms the destructive action and verifies deployment state. + Dependencies: none + Step: diagnostics Description: Dumps dependency graph information for troubleshooting pipeline execution. Dependencies: none @@ -359,6 +375,27 @@ If targeting 'deploy-prereq': [0] process-parameters [1] deploy-prereq +If targeting 'destroy': + Direct dependencies: destroy-azure-azure634f9 + Total steps: 3 + Execution order: + [0] destroy-prereq + [1] destroy-azure-azure634f9 + [2] destroy + +If targeting 'destroy-azure-azure634f9': + Direct dependencies: destroy-prereq + Total steps: 2 + Execution order: + [0] destroy-prereq + [1] destroy-azure-azure634f9 + +If targeting 'destroy-prereq': + Direct dependencies: none + Total steps: 1 + Execution order: + [0] destroy-prereq + If targeting 'diagnostics': Direct dependencies: none Total steps: 1 diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithRedisAccessKeyAuthentication_CreatesCorrectDependencies.verified.txt b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithRedisAccessKeyAuthentication_CreatesCorrectDependencies.verified.txt index 4ad5b0a55ce..308e05df6d8 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithRedisAccessKeyAuthentication_CreatesCorrectDependencies.verified.txt +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithRedisAccessKeyAuthentication_CreatesCorrectDependencies.verified.txt @@ -5,7 +5,7 @@ PIPELINE DEPENDENCY GRAPH DIAGNOSTICS This diagnostic output shows the complete pipeline dependency graph structure. Use this to understand step relationships and troubleshoot execution issues. -Total steps defined: 35 +Total steps defined: 38 Analysis for full pipeline execution (showing all steps and their relationships) @@ -42,13 +42,16 @@ Steps with no dependencies run first, followed by steps that depend on them. 26. print-dashboard-url-env 27. deploy 28. deploy-api - 29. diagnostics - 30. publish-prereq - 31. publish-azure634f9 - 32. validate-appservice-config-env - 33. publish - 34. publish-manifest - 35. push + 29. destroy-prereq + 30. destroy-azure-azure634f9 + 31. destroy + 32. diagnostics + 33. publish-prereq + 34. publish-azure634f9 + 35. validate-appservice-config-env + 36. publish + 37. publish-manifest + 38. push DETAILED STEP ANALYSIS ====================== @@ -88,6 +91,19 @@ Step: deploy-prereq Description: Prerequisite step that runs before any deploy operations. Initializes deployment environment and manages deployment state. Dependencies: ✓ process-parameters +Step: destroy + Description: Aggregation step for all destroy operations. All destroy steps should be required by this step. + Dependencies: ✓ destroy-azure-azure634f9 + +Step: destroy-azure-azure634f9 + Description: Destroys the Azure resource group and all resources for azure634f9. + Dependencies: ✓ destroy-prereq + Resource: azure634f9 (AzureEnvironmentResource) + +Step: destroy-prereq + Description: Prerequisite step that runs before any destroy operations. Confirms the destructive action and verifies deployment state. + Dependencies: none + Step: diagnostics Description: Dumps dependency graph information for troubleshooting pipeline execution. Dependencies: none @@ -321,6 +337,27 @@ If targeting 'deploy-prereq': [0] process-parameters [1] deploy-prereq +If targeting 'destroy': + Direct dependencies: destroy-azure-azure634f9 + Total steps: 3 + Execution order: + [0] destroy-prereq + [1] destroy-azure-azure634f9 + [2] destroy + +If targeting 'destroy-azure-azure634f9': + Direct dependencies: destroy-prereq + Total steps: 2 + Execution order: + [0] destroy-prereq + [1] destroy-azure-azure634f9 + +If targeting 'destroy-prereq': + Direct dependencies: none + Total steps: 1 + Execution order: + [0] destroy-prereq + If targeting 'diagnostics': Direct dependencies: none Total steps: 1 diff --git a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs index c99bf364715..3d8313f6ad5 100644 --- a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs +++ b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs @@ -1425,7 +1425,7 @@ public async Task ExecuteAsync_WithConfigurationCallback_ExecutesCallback() await pipeline.ExecuteAsync(context).DefaultTimeout(); Assert.True(callbackExecuted); - Assert.Equal(12, capturedSteps.Count); // Updated to account for all default steps including process-parameters, push, push-prereq + Assert.Equal(14, capturedSteps.Count); // Default steps: deploy, deploy-prereq, process-parameters, build, build-prereq, push, push-prereq, publish, publish-prereq, diagnostics, destroy, destroy-prereq + step1, step2 Assert.Contains(capturedSteps, s => s.Name == "deploy"); Assert.Contains(capturedSteps, s => s.Name == "process-parameters"); Assert.Contains(capturedSteps, s => s.Name == "deploy-prereq"); @@ -1436,6 +1436,8 @@ public async Task ExecuteAsync_WithConfigurationCallback_ExecutesCallback() Assert.Contains(capturedSteps, s => s.Name == "publish"); Assert.Contains(capturedSteps, s => s.Name == "publish-prereq"); Assert.Contains(capturedSteps, s => s.Name == "diagnostics"); + Assert.Contains(capturedSteps, s => s.Name == "destroy"); + Assert.Contains(capturedSteps, s => s.Name == "destroy-prereq"); Assert.Contains(capturedSteps, s => s.Name == "step1"); Assert.Contains(capturedSteps, s => s.Name == "step2"); } From a2e0559a47ceeb21f40577141e6e501f96039c49 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 09:07:11 -0700 Subject: [PATCH 02/35] Simplify destroy-prereq confirmation message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the prereq generic — each environment step already surfaces target-specific details (resource group, Helm release, compose project). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Pipelines/DistributedApplicationPipeline.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index 4ca872c8723..b0c4cf8b9e5 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -276,7 +276,7 @@ public DistributedApplicationPipeline() _steps.Add(new PipelineStep { Name = WellKnownPipelineSteps.DestroyPrereq, - Description = "Prerequisite step that runs before any destroy operations. Confirms the destructive action and verifies deployment state.", + Description = "Prerequisite step that runs before any destroy operations. Confirms the destructive action.", Action = async context => { var hostEnvironment = context.Services.GetRequiredService(); @@ -292,7 +292,7 @@ public DistributedApplicationPipeline() { var result = await interactionService.PromptNotificationAsync( "Destroy Environment", - $"This will destroy all resources for the '{hostEnvironment.EnvironmentName}' environment. This action cannot be undone. Do you want to continue?", + $"This will destroy the '{hostEnvironment.EnvironmentName}' environment. This action cannot be undone. Do you want to continue?", new NotificationInteractionOptions { Intent = MessageIntent.Confirmation, From 3a7c1b3d2e8e5713304be20fcaa4101275063369 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 09:35:21 -0700 Subject: [PATCH 03/35] Enumerate Azure resources before destroying resource group Query the resource group via ARM to list all resources before deleting, so users can see exactly what will be destroyed. The discovery phase logs each resource type and name, then reports the total count. Pipeline output: Discovering resources in myapp-rg ContainerApps/containerApps: api KeyVault/vaults: kv-myapp ContainerRegistry/registries: acrmyapp Found 3 resource(s) in myapp-rg Deleting resource group myapp-rg (3 resource(s)) If enumeration fails (e.g. permissions), deletion still proceeds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AzureEnvironmentResource.cs | 99 +++++++++++++++---- .../Internal/DefaultResourceGroupResource.cs | 8 ++ .../Internal/IProvisioningServices.cs | 6 ++ .../ProvisioningTestHelpers.cs | 6 ++ ..._DoesNotHang_step=diagnostics.verified.txt | 2 +- ...nments_Works_step=diagnostics.verified.txt | 2 +- ...ts_CreatesCorrectDependencies.verified.txt | 2 +- ...on_CreatesCorrectDependencies.verified.txt | 2 +- 8 files changed, 102 insertions(+), 25 deletions(-) diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 0955c403cc2..362a9658e9f 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -13,6 +13,7 @@ using Aspire.Hosting.Pipelines; using Azure.Core; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Aspire.Hosting.Azure; @@ -223,44 +224,100 @@ await context.ReportingStep.CompleteAsync( return; } - var deployTask = await context.ReportingStep.CreateTaskAsync( - new MarkdownString($"Deleting Azure resource group **{resourceGroupName}**"), + var credential = tokenCredentialProvider.TokenCredential; + var armClient = armClientProvider.GetArmClient(credential, subscriptionId); + var (subscription, _) = await armClient.GetSubscriptionAndTenantAsync(context.CancellationToken).ConfigureAwait(false); + + var resourceGroups = subscription.GetResourceGroups(); + + IResourceGroupResource resourceGroup; + try + { + var rgResponse = await resourceGroups.GetAsync(resourceGroupName, context.CancellationToken).ConfigureAwait(false); + resourceGroup = rgResponse.Value; + } + catch (global::Azure.RequestFailedException ex) when (ex.Status == 404) + { + // Resource group already deleted — clean up state + await deploymentStateManager.DeleteSectionAsync(azureStateSection, context.CancellationToken).ConfigureAwait(false); + + await context.ReportingStep.CompleteAsync( + new MarkdownString($"Resource group **{resourceGroupName}** not found (already deleted)"), + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); + return; + } + + // Enumerate resources in the resource group so the user can see what will be destroyed + var discoveryTask = await context.ReportingStep.CreateTaskAsync( + new MarkdownString($"Discovering resources in **{resourceGroupName}**"), context.CancellationToken).ConfigureAwait(false); - await using (deployTask.ConfigureAwait(false)) + + var resources = new List<(string Name, string ResourceType)>(); + await using (discoveryTask.ConfigureAwait(false)) { try { - var credential = tokenCredentialProvider.TokenCredential; - var armClient = armClientProvider.GetArmClient(credential, subscriptionId); - var (subscription, _) = await armClient.GetSubscriptionAndTenantAsync(context.CancellationToken).ConfigureAwait(false); + await foreach (var resource in resourceGroup.GetResourcesAsync(context.CancellationToken).ConfigureAwait(false)) + { + resources.Add(resource); + } - var resourceGroups = subscription.GetResourceGroups(); - var rgResponse = await resourceGroups.GetAsync(resourceGroupName, context.CancellationToken).ConfigureAwait(false); - var resourceGroup = rgResponse.Value; + if (resources.Count == 0) + { + await discoveryTask.CompleteAsync( + new MarkdownString($"Resource group **{resourceGroupName}** is empty"), + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); + } + else + { + foreach (var (name, type) in resources) + { + var shortType = type.StartsWith("Microsoft.", StringComparison.OrdinalIgnoreCase) + ? type["Microsoft.".Length..] + : type; + context.Logger.LogInformation(" {Type}: {Name}", shortType, name); + } + + await discoveryTask.CompleteAsync( + new MarkdownString($"Found **{resources.Count}** resource(s) in **{resourceGroupName}**"), + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); + } + } + catch (Exception ex) + { + // Non-fatal — proceed with deletion even if enumeration fails + context.Logger.LogWarning(ex, "Failed to enumerate resources in resource group '{ResourceGroupName}'", resourceGroupName); + await discoveryTask.CompleteAsync( + "Could not enumerate resources (will proceed with deletion)", + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); + } + } + // Delete the resource group + var deleteTask = await context.ReportingStep.CreateTaskAsync( + new MarkdownString($"Deleting resource group **{resourceGroupName}** ({resources.Count} resource(s))"), + context.CancellationToken).ConfigureAwait(false); + await using (deleteTask.ConfigureAwait(false)) + { + try + { await resourceGroup.DeleteAsync(global::Azure.WaitUntil.Started, context.CancellationToken).ConfigureAwait(false); // Clean up deployment state after successful destroy initiation await deploymentStateManager.DeleteSectionAsync(azureStateSection, context.CancellationToken).ConfigureAwait(false); - await deployTask.CompleteAsync( + await deleteTask.CompleteAsync( new MarkdownString($"Resource group **{resourceGroupName}** deletion initiated successfully"), CompletionState.Completed, context.CancellationToken).ConfigureAwait(false); } - catch (global::Azure.RequestFailedException ex) when (ex.Status == 404) - { - // Resource group already deleted - await deploymentStateManager.DeleteSectionAsync(azureStateSection, context.CancellationToken).ConfigureAwait(false); - - await deployTask.CompleteAsync( - new MarkdownString($"Resource group **{resourceGroupName}** not found (already deleted)"), - CompletionState.Completed, - context.CancellationToken).ConfigureAwait(false); - } catch (Exception ex) { - await deployTask.CompleteAsync( + await deleteTask.CompleteAsync( $"Failed to delete resource group '{resourceGroupName}': {ex.Message}", CompletionState.CompletedWithError, context.CancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultResourceGroupResource.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultResourceGroupResource.cs index c19a26dad01..fe35e620203 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultResourceGroupResource.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultResourceGroupResource.cs @@ -24,4 +24,12 @@ public async Task DeleteAsync(WaitUntil waitUntil, CancellationToken cancellatio { await resourceGroupResource.DeleteAsync(waitUntil, cancellationToken: cancellationToken).ConfigureAwait(false); } + + public async IAsyncEnumerable<(string Name, string ResourceType)> GetResourcesAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (var resource in resourceGroupResource.GetGenericResourcesAsync(cancellationToken: cancellationToken).ConfigureAwait(false)) + { + yield return (resource.Data.Name, resource.Data.ResourceType.ToString()); + } + } } diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs index 99d17fc16e0..afe44ec6f5d 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs @@ -168,6 +168,12 @@ internal interface IResourceGroupResource /// Deletes the resource group. /// Task DeleteAsync(WaitUntil waitUntil, CancellationToken cancellationToken = default); + + /// + /// Lists all resources in the resource group. + /// + /// A list of resources with their name and type. + IAsyncEnumerable<(string Name, string ResourceType)> GetResourcesAsync(CancellationToken cancellationToken = default); } /// diff --git a/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs b/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs index 809f861dd94..d09fe38a88d 100644 --- a/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs +++ b/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs @@ -402,6 +402,12 @@ public Task DeleteAsync(WaitUntil waitUntil, CancellationToken cancellationToken { return Task.CompletedTask; } + + public async IAsyncEnumerable<(string Name, string ResourceType)> GetResourcesAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + yield break; + } } /// diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithAzureResourceDependencies_DoesNotHang_step=diagnostics.verified.txt b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithAzureResourceDependencies_DoesNotHang_step=diagnostics.verified.txt index 06cd6bc1af9..a75a70ff1f5 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithAzureResourceDependencies_DoesNotHang_step=diagnostics.verified.txt +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithAzureResourceDependencies_DoesNotHang_step=diagnostics.verified.txt @@ -94,7 +94,7 @@ Step: destroy-azure-azure634f9 Resource: azure634f9 (AzureEnvironmentResource) Step: destroy-prereq - Description: Prerequisite step that runs before any destroy operations. Confirms the destructive action and verifies deployment state. + Description: Prerequisite step that runs before any destroy operations. Confirms the destructive action. Dependencies: none Step: diagnostics 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 425a346658d..89cb6706abc 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 @@ -121,7 +121,7 @@ Step: destroy-azure-azure634f9 Resource: azure634f9 (AzureEnvironmentResource) Step: destroy-prereq - Description: Prerequisite step that runs before any destroy operations. Confirms the destructive action and verifies deployment state. + Description: Prerequisite step that runs before any destroy operations. Confirms the destructive action. Dependencies: none Step: diagnostics diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithPrivateEndpoints_CreatesCorrectDependencies.verified.txt b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithPrivateEndpoints_CreatesCorrectDependencies.verified.txt index 64abefad9ac..ba38b09ee71 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithPrivateEndpoints_CreatesCorrectDependencies.verified.txt +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithPrivateEndpoints_CreatesCorrectDependencies.verified.txt @@ -106,7 +106,7 @@ Step: destroy-azure-azure634f9 Resource: azure634f9 (AzureEnvironmentResource) Step: destroy-prereq - Description: Prerequisite step that runs before any destroy operations. Confirms the destructive action and verifies deployment state. + Description: Prerequisite step that runs before any destroy operations. Confirms the destructive action. Dependencies: none Step: diagnostics diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithRedisAccessKeyAuthentication_CreatesCorrectDependencies.verified.txt b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithRedisAccessKeyAuthentication_CreatesCorrectDependencies.verified.txt index 308e05df6d8..5b5f40d84e2 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithRedisAccessKeyAuthentication_CreatesCorrectDependencies.verified.txt +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithRedisAccessKeyAuthentication_CreatesCorrectDependencies.verified.txt @@ -101,7 +101,7 @@ Step: destroy-azure-azure634f9 Resource: azure634f9 (AzureEnvironmentResource) Step: destroy-prereq - Description: Prerequisite step that runs before any destroy operations. Confirms the destructive action and verifies deployment state. + Description: Prerequisite step that runs before any destroy operations. Confirms the destructive action. Dependencies: none Step: diagnostics From 619ef461154abf08eca2b7116e29df84f5a09e9e Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 09:43:49 -0700 Subject: [PATCH 04/35] Add DestroyCommand unit tests and pipeline wiring tests - DestroyCommandTests: help, invalid project, --step destroy argument, --yes flag forwarding, --output-path inclusion (5 tests) - K8s: HelmUninstallStep_RequiredByDestroy verifies helm-uninstall depends on destroy-prereq - Register DestroyCommand in test DI (CliTestHelper.cs) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Commands/DestroyCommandTests.cs | 233 ++++++++++++++++++ tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 1 + .../KubernetesDeployTests.cs | 36 +++ 3 files changed, 270 insertions(+) create mode 100644 tests/Aspire.Cli.Tests/Commands/DestroyCommandTests.cs diff --git a/tests/Aspire.Cli.Tests/Commands/DestroyCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DestroyCommandTests.cs new file mode 100644 index 00000000000..f38fd716f97 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/DestroyCommandTests.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.Commands; +using Aspire.Cli.Interaction; +using Aspire.Cli.Tests.Utils; +using Aspire.Cli.Tests.TestServices; +using Microsoft.Extensions.DependencyInjection; +using Aspire.Cli.Utils; +using Microsoft.AspNetCore.InternalTesting; + +namespace Aspire.Cli.Tests.Commands; + +public class DestroyCommandTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task DestroyCommandWithHelpArgumentReturnsZero() + { + using var tempRepo = TemporaryWorkspace.Create(outputHelper); + + var services = CliTestHelper.CreateServiceCollection(tempRepo, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("destroy --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task DestroyCommandFailsWithInvalidProjectFile() + { + using var tempRepo = TemporaryWorkspace.Create(outputHelper); + + var services = CliTestHelper.CreateServiceCollection(tempRepo, outputHelper, options => + { + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner + { + GetAppHostInformationAsyncCallback = (projectFile, options, cancellationToken) => + { + return (1, false, null); + } + }; + return runner; + }; + }); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + + var result = command.Parse("destroy --apphost invalid.csproj"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.FailedToFindProject, exitCode); + } + + [Fact] + public async Task DestroyCommandPassesCorrectStepArgument() + { + 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) => + { + Assert.True(options.NoLaunchProfile); + + Assert.Contains("--operation", args); + Assert.Contains("publish", args); + Assert.Contains("--step", args); + Assert.Contains("destroy", args); + + var destroyCompleted = new TaskCompletionSource(); + var backchannel = new TestAppHostBackchannel + { + RequestStopAsyncCalled = destroyCompleted + }; + backchannelCompletionSource?.SetResult(backchannel); + await destroyCompleted.Task.DefaultTimeout(); + return 0; + } + }; + + return runner; + }; + + options.PublishCommandPrompterFactory = (sp) => + { + var interactionService = sp.GetRequiredService(); + return new TestDeployCommandPrompter(interactionService); + }; + }); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + + var result = command.Parse("destroy"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task DestroyCommandForwardsYesFlag() + { + 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) => + { + Assert.Contains("--yes", args); + Assert.Contains("true", args); + Assert.Contains("--step", args); + Assert.Contains("destroy", args); + + var destroyCompleted = new TaskCompletionSource(); + var backchannel = new TestAppHostBackchannel + { + RequestStopAsyncCalled = destroyCompleted + }; + backchannelCompletionSource?.SetResult(backchannel); + await destroyCompleted.Task.DefaultTimeout(); + return 0; + } + }; + + return runner; + }; + + options.PublishCommandPrompterFactory = (sp) => + { + var interactionService = sp.GetRequiredService(); + return new TestDeployCommandPrompter(interactionService); + }; + }); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + + var result = command.Parse("destroy --yes"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task DestroyCommandIncludesOutputPathWhenSpecified() + { + using var tempRepo = TemporaryWorkspace.Create(outputHelper); + var testOutputPath = Path.Combine(Path.GetTempPath(), "test-destroy"); + + 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) => + { + Assert.Contains("--output-path", args); + Assert.Contains(testOutputPath, args); + Assert.Contains("--step", args); + Assert.Contains("destroy", args); + + var destroyCompleted = new TaskCompletionSource(); + var backchannel = new TestAppHostBackchannel + { + RequestStopAsyncCalled = destroyCompleted + }; + backchannelCompletionSource?.SetResult(backchannel); + await destroyCompleted.Task.DefaultTimeout(); + return 0; + } + }; + + return runner; + }; + + options.PublishCommandPrompterFactory = (sp) => + { + var interactionService = sp.GetRequiredService(); + return new TestDeployCommandPrompter(interactionService); + }; + }); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + + var result = command.Parse($"destroy --output-path {testOutputPath}"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(0, exitCode); + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 877abdfba09..a6b19eb716c 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -184,6 +184,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs index 53789ac1726..8bc16024391 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs @@ -393,6 +393,42 @@ public async Task HelmUninstallStepIsCreated() Assert.Contains(logs, msg => msg.Contains("helm-uninstall-env")); } + [Fact] + public async Task HelmUninstallStep_RequiredByDestroy() + { + using var tempDir = new TestTempDirectory(); + + var builder = TestDistributedApplicationBuilder.Create( + DistributedApplicationOperation.Publish, + tempDir.Path, + step: WellKnownPipelineSteps.Diagnostics); + var mockActivityReporter = new TestPipelineActivityReporter(output); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(mockActivityReporter); + + builder.AddKubernetesEnvironment("env"); + builder.AddContainer("api", "myimage"); + + using var app = builder.Build(); + await app.RunAsync(); + + var logs = mockActivityReporter.LoggedMessages + .Where(s => s.StepTitle == "diagnostics") + .Select(s => s.Message) + .ToList(); + + output.WriteLine("Diagnostics logs:"); + foreach (var log in logs) + { + output.WriteLine($" {log}"); + } + + // Verify helm-uninstall-env depends on destroy-prereq (because it's RequiredBy destroy) + var helmUninstallLines = logs.Where(l => l.Contains("helm-uninstall-env")).ToList(); + Assert.Contains(helmUninstallLines, msg => msg.Contains("destroy-prereq")); + } + [Fact] public async Task MultipleContainersGenerateMultiplePrintSummarySteps() { From 0943758e2edb81671021c1df6f390fdf465cc390 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 11:40:47 -0700 Subject: [PATCH 05/35] Move confirmation prompts to environment-specific destroy steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each environment step now owns its own confirmation with full context: - Azure: discovers resources in RG, then asks to confirm deletion - Docker Compose: asks to confirm compose down - Kubernetes: asks to confirm helm uninstall with release name + namespace Pipeline layering: destroy → destroy-{env} (prompt) → action step (no prompt) This means 'aspire do docker-compose-down' skips the prompt (explicit action), while 'aspire destroy' chains through the confirmation layer. The generic destroy-prereq is now a plain no-op placeholder step. --yes skips all confirmation prompts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AzureEnvironmentResource.cs | 33 +++++++++++ .../DockerComposeEnvironmentResource.cs | 48 +++++++++++++++- .../Deployment/HelmDeploymentEngine.cs | 55 ++++++++++++++++++- .../DistributedApplicationPipeline.cs | 37 +------------ ..._DoesNotHang_step=diagnostics.verified.txt | 2 +- ...nments_Works_step=diagnostics.verified.txt | 2 +- ...ts_CreatesCorrectDependencies.verified.txt | 2 +- ...on_CreatesCorrectDependencies.verified.txt | 2 +- .../KubernetesDeployTests.cs | 4 +- 9 files changed, 139 insertions(+), 46 deletions(-) diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 362a9658e9f..c0de07df2f3 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -297,6 +297,39 @@ await discoveryTask.CompleteAsync( } } + // Confirm destruction with the user (unless --yes was specified) + var options = context.Services.GetRequiredService>(); + if (!options.Value.Yes) + { + var interactionService = context.Services.GetRequiredService(); + + if (interactionService.IsAvailable) + { + var confirmMessage = resources.Count > 0 + ? $"Delete resource group '{resourceGroupName}' with {resources.Count} resource(s)? This action cannot be undone." + : $"Delete resource group '{resourceGroupName}'? This action cannot be undone."; + + var result = await interactionService.PromptNotificationAsync( + "Destroy Azure Resources", + confirmMessage, + new NotificationInteractionOptions + { + Intent = MessageIntent.Confirmation, + ShowSecondaryButton = true, + ShowDismiss = false, + PrimaryButtonText = "Yes, destroy", + SecondaryButtonText = "Cancel" + }, + context.CancellationToken).ConfigureAwait(false); + + if (result.Canceled || !result.Data) + { + context.Logger.LogInformation("User canceled the destroy operation."); + throw new OperationCanceledException("Destroy operation canceled by user."); + } + } + } + // Delete the resource group var deleteTask = await context.ReportingStep.CreateTaskAsync( new MarkdownString($"Deleting resource group **{resourceGroupName}** ({resources.Count} resource(s))"), diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs index 1357e8c984a..15203437baf 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. #pragma warning disable ASPIRECONTAINERRUNTIME001 +#pragma warning disable ASPIREINTERACTION001 using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; @@ -13,6 +14,8 @@ using Aspire.Hosting.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Aspire.Hosting.Docker; @@ -127,10 +130,19 @@ public DockerComposeEnvironmentResource(string name) : base(name) Action = ctx => DockerComposeDownAsync(ctx), Tags = ["docker-compose-down"] }; - dockerComposeDownStep.RequiredBy(WellKnownPipelineSteps.Destroy); - dockerComposeDownStep.DependsOn(WellKnownPipelineSteps.DestroyPrereq); steps.Add(dockerComposeDownStep); + var dockerComposeDestroyStep = new PipelineStep + { + Name = $"destroy-compose-{Name}", + Description = $"Confirms and destroys the Docker Compose environment {Name}.", + Action = ctx => ConfirmDestroyAsync(ctx, $"Shut down Docker Compose environment '{Name}'? This will stop and remove all containers, networks, and volumes."), + DependsOnSteps = [WellKnownPipelineSteps.DestroyPrereq] + }; + dockerComposeDestroyStep.RequiredBy(WellKnownPipelineSteps.Destroy); + dockerComposeDownStep.DependsOn(dockerComposeDestroyStep); + steps.Add(dockerComposeDestroyStep); + return steps; })); @@ -287,6 +299,38 @@ await deployTask.CompleteAsync( } } + private static async Task ConfirmDestroyAsync(PipelineStepContext context, string message) + { + var options = context.Services.GetRequiredService>(); + + if (!options.Value.Yes) + { + var interactionService = context.Services.GetRequiredService(); + + if (interactionService.IsAvailable) + { + var result = await interactionService.PromptNotificationAsync( + "Destroy Environment", + message, + new NotificationInteractionOptions + { + Intent = MessageIntent.Confirmation, + ShowSecondaryButton = true, + ShowDismiss = false, + PrimaryButtonText = "Yes, destroy", + SecondaryButtonText = "Cancel" + }, + context.CancellationToken).ConfigureAwait(false); + + if (result.Canceled || !result.Data) + { + context.Logger.LogInformation("User canceled the destroy operation."); + throw new OperationCanceledException("Destroy operation canceled by user."); + } + } + } + } + private async Task PrepareAsync(PipelineStepContext context) { var envFilePath = GetEnvFilePath(context, this); diff --git a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs index 8c5f2b9f05c..a61a169472f 100644 --- a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs +++ b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. #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 ASPIREINTERACTION001 using System.Globalization; using System.Text; @@ -15,6 +16,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Aspire.Hosting.Kubernetes; @@ -152,7 +154,7 @@ internal static Task> CreateStepsAsync( instructionsStep.RequiredBy(WellKnownPipelineSteps.Deploy); steps.Add(instructionsStep); - // Step 4: Helm uninstall (teardown) + // Step 4: Helm uninstall (teardown, callable via aspire do) var helmUninstallStep = new PipelineStep { Name = $"helm-uninstall-{environment.Name}", @@ -160,10 +162,25 @@ internal static Task> CreateStepsAsync( Tags = [HelmUninstallTag], Action = ctx => HelmUninstallAsync(ctx, environment) }; - helmUninstallStep.RequiredBy(WellKnownPipelineSteps.Destroy); - helmUninstallStep.DependsOn(WellKnownPipelineSteps.DestroyPrereq); steps.Add(helmUninstallStep); + // Step 5: Destroy confirmation (prompts before uninstalling, used by aspire destroy) + var helmDestroyStep = new PipelineStep + { + Name = $"destroy-helm-{environment.Name}", + Description = $"Confirms destruction of the Helm deployment for {environment.Name}.", + Action = async ctx => + { + var @namespace = await ResolveNamespaceAsync(ctx, environment).ConfigureAwait(false); + var releaseName = await ResolveReleaseNameAsync(ctx, environment).ConfigureAwait(false); + await ConfirmDestroyAsync(ctx, $"Uninstall Helm release '{releaseName}' from namespace '{@namespace}'? This action cannot be undone.").ConfigureAwait(false); + }, + DependsOnSteps = [WellKnownPipelineSteps.DestroyPrereq] + }; + helmDestroyStep.RequiredBy(WellKnownPipelineSteps.Destroy); + helmUninstallStep.DependsOn(helmDestroyStep); + steps.Add(helmDestroyStep); + return Task.FromResult>(steps); } @@ -571,6 +588,38 @@ await uninstallTask.CompleteAsync( } } + private static async Task ConfirmDestroyAsync(PipelineStepContext context, string message) + { + var options = context.Services.GetRequiredService>(); + + if (!options.Value.Yes) + { + var interactionService = context.Services.GetRequiredService(); + + if (interactionService.IsAvailable) + { + var result = await interactionService.PromptNotificationAsync( + "Destroy Environment", + message, + new NotificationInteractionOptions + { + Intent = MessageIntent.Confirmation, + ShowSecondaryButton = true, + ShowDismiss = false, + PrimaryButtonText = "Yes, destroy", + SecondaryButtonText = "Cancel" + }, + context.CancellationToken).ConfigureAwait(false); + + if (result.Canceled || !result.Data) + { + context.Logger.LogInformation("User canceled the destroy operation."); + throw new OperationCanceledException("Destroy operation canceled by user."); + } + } + } + } + private static async Task> GetServiceEndpointsAsync( string serviceName, string @namespace, diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index b0c4cf8b9e5..75f181a0e04 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -276,41 +276,8 @@ public DistributedApplicationPipeline() _steps.Add(new PipelineStep { Name = WellKnownPipelineSteps.DestroyPrereq, - Description = "Prerequisite step that runs before any destroy operations. Confirms the destructive action.", - Action = async context => - { - var hostEnvironment = context.Services.GetRequiredService(); - var options = context.Services.GetRequiredService>(); - - context.Logger.LogInformation("Preparing to destroy environment '{EnvironmentName}'", hostEnvironment.EnvironmentName); - - if (!options.Value.Yes) - { - var interactionService = context.Services.GetRequiredService(); - - if (interactionService.IsAvailable) - { - var result = await interactionService.PromptNotificationAsync( - "Destroy Environment", - $"This will destroy the '{hostEnvironment.EnvironmentName}' environment. This action cannot be undone. Do you want to continue?", - new NotificationInteractionOptions - { - Intent = MessageIntent.Confirmation, - ShowSecondaryButton = true, - ShowDismiss = false, - PrimaryButtonText = "Yes, destroy", - SecondaryButtonText = "Cancel" - }, - context.CancellationToken).ConfigureAwait(false); - - if (result.Canceled || !result.Data) - { - context.Logger.LogInformation("User canceled the destroy operation."); - throw new OperationCanceledException("Destroy operation canceled by user."); - } - } - } - } + Description = "Prerequisite step that runs before any destroy operations.", + Action = _ => Task.CompletedTask, }); } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithAzureResourceDependencies_DoesNotHang_step=diagnostics.verified.txt b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithAzureResourceDependencies_DoesNotHang_step=diagnostics.verified.txt index a75a70ff1f5..293bdcf1241 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithAzureResourceDependencies_DoesNotHang_step=diagnostics.verified.txt +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithAzureResourceDependencies_DoesNotHang_step=diagnostics.verified.txt @@ -94,7 +94,7 @@ Step: destroy-azure-azure634f9 Resource: azure634f9 (AzureEnvironmentResource) Step: destroy-prereq - Description: Prerequisite step that runs before any destroy operations. Confirms the destructive action. + Description: Prerequisite step that runs before any destroy operations. Dependencies: none Step: diagnostics 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 89cb6706abc..906b6f51b93 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 @@ -121,7 +121,7 @@ Step: destroy-azure-azure634f9 Resource: azure634f9 (AzureEnvironmentResource) Step: destroy-prereq - Description: Prerequisite step that runs before any destroy operations. Confirms the destructive action. + Description: Prerequisite step that runs before any destroy operations. Dependencies: none Step: diagnostics diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithPrivateEndpoints_CreatesCorrectDependencies.verified.txt b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithPrivateEndpoints_CreatesCorrectDependencies.verified.txt index ba38b09ee71..c1545d05d81 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithPrivateEndpoints_CreatesCorrectDependencies.verified.txt +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithPrivateEndpoints_CreatesCorrectDependencies.verified.txt @@ -106,7 +106,7 @@ Step: destroy-azure-azure634f9 Resource: azure634f9 (AzureEnvironmentResource) Step: destroy-prereq - Description: Prerequisite step that runs before any destroy operations. Confirms the destructive action. + Description: Prerequisite step that runs before any destroy operations. Dependencies: none Step: diagnostics diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithRedisAccessKeyAuthentication_CreatesCorrectDependencies.verified.txt b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithRedisAccessKeyAuthentication_CreatesCorrectDependencies.verified.txt index 5b5f40d84e2..f3050f4952d 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithRedisAccessKeyAuthentication_CreatesCorrectDependencies.verified.txt +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithRedisAccessKeyAuthentication_CreatesCorrectDependencies.verified.txt @@ -101,7 +101,7 @@ Step: destroy-azure-azure634f9 Resource: azure634f9 (AzureEnvironmentResource) Step: destroy-prereq - Description: Prerequisite step that runs before any destroy operations. Confirms the destructive action. + Description: Prerequisite step that runs before any destroy operations. Dependencies: none Step: diagnostics diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs index 8bc16024391..07e42cb4ec6 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs @@ -424,9 +424,9 @@ public async Task HelmUninstallStep_RequiredByDestroy() output.WriteLine($" {log}"); } - // Verify helm-uninstall-env depends on destroy-prereq (because it's RequiredBy destroy) + // Verify helm-uninstall-env depends on destroy-helm-env (the prompt layer) var helmUninstallLines = logs.Where(l => l.Contains("helm-uninstall-env")).ToList(); - Assert.Contains(helmUninstallLines, msg => msg.Contains("destroy-prereq")); + Assert.Contains(helmUninstallLines, msg => msg.Contains("destroy-helm-env")); } [Fact] From 6cbd33a6c017cba0f72dd8ac0356a41290ae2989 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 11:48:26 -0700 Subject: [PATCH 06/35] Update deployment E2E tests to use aspire destroy for cleanup Replace manual cleanup commands with 'aspire destroy --yes': - Azure (14 files): add aspire destroy step before exit, keep CleanupResourceGroupAsync as safety net in finally block - Docker (2 tests): replace 'docker compose down' with aspire destroy - Podman (1 test): replace 'podman compose down' with aspire destroy - Kubernetes (1 test): replace 'helm uninstall' with aspire destroy, keep KinD cluster deletion separate Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DockerDeploymentTests.cs | 15 +++++++++------ .../KubernetesPublishTests.cs | 8 +++++--- .../PodmanDeploymentTests.cs | 8 +++++--- .../AcaCompactNamingDeploymentTests.cs | 9 ++++++++- .../AcaCompactNamingUpgradeDeploymentTests.cs | 9 ++++++++- .../AksStarterDeploymentTests.cs | 10 +++++++++- .../AksStarterWithRedisDeploymentTests.cs | 10 +++++++++- .../AzureAppConfigDeploymentTests.cs | 9 ++++++++- .../AzureContainerRegistryDeploymentTests.cs | 9 ++++++++- .../AzureEventHubsDeploymentTests.cs | 9 ++++++++- .../AzureKeyVaultDeploymentTests.cs | 9 ++++++++- .../AzureLogAnalyticsDeploymentTests.cs | 9 ++++++++- .../AzureServiceBusDeploymentTests.cs | 9 ++++++++- .../AzureStorageDeploymentTests.cs | 9 ++++++++- .../VnetKeyVaultInfraDeploymentTests.cs | 9 ++++++++- .../VnetSqlServerInfraDeploymentTests.cs | 9 ++++++++- .../VnetStorageBlobInfraDeploymentTests.cs | 9 ++++++++- 17 files changed, 133 insertions(+), 26 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs index 5679a174255..d47ab506da0 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.EndToEnd.Tests.Helpers; +using Aspire.Cli.Resources; using Aspire.Cli.Tests.Utils; using Aspire.TestUtilities; using Hex1b.Automation; @@ -130,10 +131,11 @@ public async Task CreateAndDeployToDockerCompose() await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); - // Step 11: Clean up - stop and remove containers - await auto.TypeAsync("cd deploy-output && docker compose down --volumes --remove-orphans 2>/dev/null || true"); + // Step 11: Clean up - destroy the deployment using aspire destroy + await auto.TypeAsync("aspire destroy --yes"); await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60)); + await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(2)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(1)); await auto.TypeAsync("exit"); await auto.EnterAsync(); @@ -254,10 +256,11 @@ public async Task CreateAndDeployToDockerComposeInteractive() await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); - // Step 11: Clean up - stop and remove containers - await auto.TypeAsync("cd deploy-output && docker compose down --volumes --remove-orphans 2>/dev/null || true"); + // Step 11: Clean up - destroy the deployment using aspire destroy + await auto.TypeAsync("aspire destroy --yes"); await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60)); + await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(2)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(1)); await auto.TypeAsync("exit"); await auto.EnterAsync(); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs index 8aac2ef7af3..90082079ff0 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.EndToEnd.Tests.Helpers; +using Aspire.Cli.Resources; using Aspire.Cli.Tests.Utils; using Aspire.TestUtilities; using Hex1b.Automation; @@ -293,10 +294,11 @@ await auto.TypeAsync("helm install aspire-app helm-output " + // Phase 6: Cleanup // ===================================================================== - // Uninstall the Helm release - await auto.TypeAsync("helm uninstall aspire-app"); + // Destroy the deployment using aspire destroy (runs helm uninstall) + await auto.TypeAsync("aspire destroy --yes"); await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); + await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(2)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(1)); // Delete the KinD cluster await auto.TypeAsync($"kind delete cluster --name={clusterName}"); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs index 5d7a0a269a8..d86ec1ee10d 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.EndToEnd.Tests.Helpers; +using Aspire.Cli.Resources; using Aspire.Cli.Tests.Utils; using Aspire.TestUtilities; using Hex1b.Automation; @@ -124,10 +125,11 @@ public async Task CreateAndDeployToDockerComposeWithPodman() await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); - // Step 11: Clean up - stop and remove containers using podman - await auto.TypeAsync("cd deploy-output && podman compose down --volumes --remove-orphans 2>/dev/null || true"); + // Step 11: Clean up - destroy the deployment using aspire destroy + await auto.TypeAsync("aspire destroy --yes"); await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60)); + await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(2)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(1)); await auto.TypeAsync("exit"); await auto.EnterAsync(); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs index b7b4404109e..f030678204b 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs @@ -152,7 +152,14 @@ await auto.TypeAsync( await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); - // Step 9: Exit + // Step 9: Clean up Azure resources using aspire destroy + output.WriteLine("Step 9: Destroying Azure deployment..."); + await auto.TypeAsync("aspire destroy --yes"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + // Step 10: Exit await auto.TypeAsync("exit"); await auto.EnterAsync(); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs index a18e77db84d..dfdef1be3d2 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs @@ -361,7 +361,14 @@ await auto.TypeAsync( await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); - // Step 15: Exit + // Step 15: Clean up Azure resources using aspire destroy + output.WriteLine("Step 15: Destroying Azure deployment..."); + await auto.TypeAsync("aspire destroy --yes"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + // Step 16: Exit await auto.TypeAsync("exit"); await auto.EnterAsync(); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs index 8afd919004c..d6a8526c4cf 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Cli.Resources; using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; using Hex1b.Automation; @@ -328,7 +329,14 @@ await auto.TypeAsync($"dotnet publish {projectName}.ApiService/{projectName}.Api await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(10)); - // Step 28: Exit terminal + // Step 28: Clean up Azure resources using aspire destroy + output.WriteLine("Step 28: Destroying Azure deployment..."); + await auto.TypeAsync("aspire destroy --yes"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + // Step 29: Exit terminal await auto.TypeAsync("exit"); await auto.EnterAsync(); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs index d3037746887..285eae54070 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Cli.Resources; using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; using Hex1b.Automation; @@ -357,7 +358,14 @@ await auto.TypeAsync($"dotnet publish {projectName}.ApiService/{projectName}.Api await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(10)); - // Step 30: Exit terminal + // Step 30: Clean up Azure resources using aspire destroy + output.WriteLine("Step 30: Destroying Azure deployment..."); + await auto.TypeAsync("aspire destroy --yes"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + // Step 31: Exit terminal await auto.TypeAsync("exit"); await auto.EnterAsync(); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs index 6b68b46ca7c..75e113163a9 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs @@ -158,7 +158,14 @@ private async Task DeployAzureAppConfigResourceCore(CancellationToken cancellati await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); - // Step 9: Exit terminal + // Step 9: Clean up Azure resources using aspire destroy + output.WriteLine("Step 9: Destroying Azure deployment..."); + await auto.TypeAsync("aspire destroy --yes"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + // Step 10: Exit terminal await auto.TypeAsync("exit"); await auto.EnterAsync(); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs index 13a2d4f408e..01f4739ffd8 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs @@ -133,7 +133,14 @@ private async Task DeployAzureContainerRegistryResourceCore(CancellationToken ca await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); - // Step 9: Exit terminal + // Step 9: Clean up Azure resources using aspire destroy + output.WriteLine("Step 9: Destroying Azure deployment..."); + await auto.TypeAsync("aspire destroy --yes"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + // Step 10: Exit terminal await auto.TypeAsync("exit"); await auto.EnterAsync(); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs index 6080ca7800b..1b06d908eeb 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs @@ -158,7 +158,14 @@ private async Task DeployAzureEventHubsResourceCore(CancellationToken cancellati await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); - // Step 9: Exit terminal + // Step 9: Clean up Azure resources using aspire destroy + output.WriteLine("Step 9: Destroying Azure deployment..."); + await auto.TypeAsync("aspire destroy --yes"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + // Step 10: Exit terminal await auto.TypeAsync("exit"); await auto.EnterAsync(); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs index 7c7867c1fd7..459284003cc 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs @@ -158,7 +158,14 @@ private async Task DeployAzureKeyVaultResourceCore(CancellationToken cancellatio await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); - // Step 9: Exit terminal + // Step 9: Clean up Azure resources using aspire destroy + output.WriteLine("Step 9: Destroying Azure deployment..."); + await auto.TypeAsync("aspire destroy --yes"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + // Step 10: Exit terminal await auto.TypeAsync("exit"); await auto.EnterAsync(); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs index c3868fd2a78..931f73e8ba1 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs @@ -133,7 +133,14 @@ private async Task DeployAzureLogAnalyticsResourceCore(CancellationToken cancell await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); - // Step 9: Exit terminal + // Step 9: Clean up Azure resources using aspire destroy + output.WriteLine("Step 9: Destroying Azure deployment..."); + await auto.TypeAsync("aspire destroy --yes"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + // Step 10: Exit terminal await auto.TypeAsync("exit"); await auto.EnterAsync(); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs index 7ad06561e4f..7fd8dbb60ef 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs @@ -160,7 +160,14 @@ private async Task DeployAzureServiceBusResourceCore(CancellationToken cancellat await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); - // Step 9: Exit terminal + // Step 9: Clean up Azure resources using aspire destroy + output.WriteLine("Step 9: Destroying Azure deployment..."); + await auto.TypeAsync("aspire destroy --yes"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + // Step 10: Exit terminal await auto.TypeAsync("exit"); await auto.EnterAsync(); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs index 855fdfbf673..d2a2eabc765 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs @@ -163,7 +163,14 @@ private async Task DeployAzureStorageResourceCore(CancellationToken cancellation await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); - // Step 9: Exit terminal + // Step 9: Clean up Azure resources using aspire destroy + output.WriteLine("Step 9: Destroying Azure deployment..."); + await auto.TypeAsync("aspire destroy --yes"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + // Step 10: Exit terminal await auto.TypeAsync("exit"); await auto.EnterAsync(); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultInfraDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultInfraDeploymentTests.cs index 4071ec2d24d..8b06673622d 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultInfraDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultInfraDeploymentTests.cs @@ -173,7 +173,14 @@ await auto.TypeAsync($"az network vnet list -g \"{resourceGroupName}\" --query \ await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60)); - // Step 9: Exit terminal + // Step 9: Clean up Azure resources using aspire destroy + output.WriteLine("Step 9: Destroying Azure deployment..."); + await auto.TypeAsync("aspire destroy --yes"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + // Step 10: Exit terminal await auto.TypeAsync("exit"); await auto.EnterAsync(); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerInfraDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerInfraDeploymentTests.cs index c4a85e3a4f5..7f589131061 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerInfraDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerInfraDeploymentTests.cs @@ -174,7 +174,14 @@ await auto.TypeAsync($"az network vnet list -g \"{resourceGroupName}\" --query \ await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60)); - // Step 9: Exit terminal + // Step 9: Clean up Azure resources using aspire destroy + output.WriteLine("Step 9: Destroying Azure deployment..."); + await auto.TypeAsync("aspire destroy --yes"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + // Step 10: Exit terminal await auto.TypeAsync("exit"); await auto.EnterAsync(); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobInfraDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobInfraDeploymentTests.cs index 57b71c579d2..584e66de8ea 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobInfraDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobInfraDeploymentTests.cs @@ -175,7 +175,14 @@ await auto.TypeAsync($"az network vnet list -g \"{resourceGroupName}\" --query \ await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60)); - // Step 9: Exit terminal + // Step 9: Clean up Azure resources using aspire destroy + output.WriteLine("Step 9: Destroying Azure deployment..."); + await auto.TypeAsync("aspire destroy --yes"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + // Step 10: Exit terminal await auto.TypeAsync("exit"); await auto.EnterAsync(); From a6202c2cd45d36982625d590e7068a6a15f07967 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 12:09:49 -0700 Subject: [PATCH 07/35] Fix review findings: non-interactive guard and step wiring - Fail fast when --yes is not set and interactivity is unavailable, instead of silently proceeding with destruction - Consolidate destroy steps: each environment's destroy step does confirm + action in one step, keeping standalone action steps (docker-compose-down, helm-uninstall) clean for aspire do usage - destroy-prereq is now a plain no-op placeholder Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AzureEnvironmentResource.cs | 45 ++++++------- .../DockerComposeEnvironmentResource.cs | 60 ++++++++++-------- .../Deployment/HelmDeploymentEngine.cs | 63 ++++++++++--------- 3 files changed, 90 insertions(+), 78 deletions(-) diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index c0de07df2f3..d7664578ba6 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -303,30 +303,33 @@ await discoveryTask.CompleteAsync( { var interactionService = context.Services.GetRequiredService(); - if (interactionService.IsAvailable) + if (!interactionService.IsAvailable) { - var confirmMessage = resources.Count > 0 - ? $"Delete resource group '{resourceGroupName}' with {resources.Count} resource(s)? This action cannot be undone." - : $"Delete resource group '{resourceGroupName}'? This action cannot be undone."; - - var result = await interactionService.PromptNotificationAsync( - "Destroy Azure Resources", - confirmMessage, - new NotificationInteractionOptions - { - Intent = MessageIntent.Confirmation, - ShowSecondaryButton = true, - ShowDismiss = false, - PrimaryButtonText = "Yes, destroy", - SecondaryButtonText = "Cancel" - }, - context.CancellationToken).ConfigureAwait(false); + throw new InvalidOperationException( + "Cannot perform destructive operation without confirmation. Use --yes to skip the confirmation prompt in non-interactive mode."); + } - if (result.Canceled || !result.Data) + var confirmMessage = resources.Count > 0 + ? $"Delete resource group '{resourceGroupName}' with {resources.Count} resource(s)? This action cannot be undone." + : $"Delete resource group '{resourceGroupName}'? This action cannot be undone."; + + var result = await interactionService.PromptNotificationAsync( + "Destroy Azure Resources", + confirmMessage, + new NotificationInteractionOptions { - context.Logger.LogInformation("User canceled the destroy operation."); - throw new OperationCanceledException("Destroy operation canceled by user."); - } + Intent = MessageIntent.Confirmation, + ShowSecondaryButton = true, + ShowDismiss = false, + PrimaryButtonText = "Yes, destroy", + SecondaryButtonText = "Cancel" + }, + context.CancellationToken).ConfigureAwait(false); + + if (result.Canceled || !result.Data) + { + context.Logger.LogInformation("User canceled the destroy operation."); + throw new OperationCanceledException("Destroy operation canceled by user."); } } diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs index 15203437baf..231436b2153 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs @@ -124,25 +124,28 @@ public DockerComposeEnvironmentResource(string name) : base(name) dockerComposeUpStep.RequiredBy(WellKnownPipelineSteps.Deploy); steps.Add(dockerComposeUpStep); - var dockerComposeDownStep = new PipelineStep - { - Name = $"docker-compose-down-{Name}", - Action = ctx => DockerComposeDownAsync(ctx), - Tags = ["docker-compose-down"] - }; - steps.Add(dockerComposeDownStep); - var dockerComposeDestroyStep = new PipelineStep { Name = $"destroy-compose-{Name}", Description = $"Confirms and destroys the Docker Compose environment {Name}.", - Action = ctx => ConfirmDestroyAsync(ctx, $"Shut down Docker Compose environment '{Name}'? This will stop and remove all containers, networks, and volumes."), + Action = async ctx => + { + await ConfirmDestroyAsync(ctx, $"Shut down Docker Compose environment '{Name}'? This will stop and remove all containers, networks, and volumes.").ConfigureAwait(false); + await DockerComposeDownAsync(ctx).ConfigureAwait(false); + }, DependsOnSteps = [WellKnownPipelineSteps.DestroyPrereq] }; dockerComposeDestroyStep.RequiredBy(WellKnownPipelineSteps.Destroy); - dockerComposeDownStep.DependsOn(dockerComposeDestroyStep); steps.Add(dockerComposeDestroyStep); + var dockerComposeDownStep = new PipelineStep + { + Name = $"docker-compose-down-{Name}", + Action = ctx => DockerComposeDownAsync(ctx), + Tags = ["docker-compose-down"] + }; + steps.Add(dockerComposeDownStep); + return steps; })); @@ -307,26 +310,29 @@ private static async Task ConfirmDestroyAsync(PipelineStepContext context, strin { var interactionService = context.Services.GetRequiredService(); - if (interactionService.IsAvailable) + if (!interactionService.IsAvailable) { - var result = await interactionService.PromptNotificationAsync( - "Destroy Environment", - message, - new NotificationInteractionOptions - { - Intent = MessageIntent.Confirmation, - ShowSecondaryButton = true, - ShowDismiss = false, - PrimaryButtonText = "Yes, destroy", - SecondaryButtonText = "Cancel" - }, - context.CancellationToken).ConfigureAwait(false); + throw new InvalidOperationException( + "Cannot perform destructive operation without confirmation. Use --yes to skip the confirmation prompt in non-interactive mode."); + } - if (result.Canceled || !result.Data) + var result = await interactionService.PromptNotificationAsync( + "Destroy Environment", + message, + new NotificationInteractionOptions { - context.Logger.LogInformation("User canceled the destroy operation."); - throw new OperationCanceledException("Destroy operation canceled by user."); - } + Intent = MessageIntent.Confirmation, + ShowSecondaryButton = true, + ShowDismiss = false, + PrimaryButtonText = "Yes, destroy", + SecondaryButtonText = "Cancel" + }, + context.CancellationToken).ConfigureAwait(false); + + if (result.Canceled || !result.Data) + { + context.Logger.LogInformation("User canceled the destroy operation."); + throw new OperationCanceledException("Destroy operation canceled by user."); } } } diff --git a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs index a61a169472f..e6ce1ffd3e1 100644 --- a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs +++ b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs @@ -154,33 +154,33 @@ internal static Task> CreateStepsAsync( instructionsStep.RequiredBy(WellKnownPipelineSteps.Deploy); steps.Add(instructionsStep); - // Step 4: Helm uninstall (teardown, callable via aspire do) - var helmUninstallStep = new PipelineStep - { - Name = $"helm-uninstall-{environment.Name}", - Description = $"Uninstalls the Helm release for {environment.Name}.", - Tags = [HelmUninstallTag], - Action = ctx => HelmUninstallAsync(ctx, environment) - }; - steps.Add(helmUninstallStep); - - // Step 5: Destroy confirmation (prompts before uninstalling, used by aspire destroy) + // Step 4: Destroy confirmation + uninstall (used by aspire destroy) var helmDestroyStep = new PipelineStep { Name = $"destroy-helm-{environment.Name}", - Description = $"Confirms destruction of the Helm deployment for {environment.Name}.", + Description = $"Confirms and destroys the Helm deployment for {environment.Name}.", Action = async ctx => { var @namespace = await ResolveNamespaceAsync(ctx, environment).ConfigureAwait(false); var releaseName = await ResolveReleaseNameAsync(ctx, environment).ConfigureAwait(false); await ConfirmDestroyAsync(ctx, $"Uninstall Helm release '{releaseName}' from namespace '{@namespace}'? This action cannot be undone.").ConfigureAwait(false); + await HelmUninstallAsync(ctx, environment).ConfigureAwait(false); }, DependsOnSteps = [WellKnownPipelineSteps.DestroyPrereq] }; helmDestroyStep.RequiredBy(WellKnownPipelineSteps.Destroy); - helmUninstallStep.DependsOn(helmDestroyStep); steps.Add(helmDestroyStep); + // Step 5: Helm uninstall (teardown, callable directly via aspire do without confirmation) + var helmUninstallStep = new PipelineStep + { + Name = $"helm-uninstall-{environment.Name}", + Description = $"Uninstalls the Helm release for {environment.Name}.", + Tags = [HelmUninstallTag], + Action = ctx => HelmUninstallAsync(ctx, environment) + }; + steps.Add(helmUninstallStep); + return Task.FromResult>(steps); } @@ -596,26 +596,29 @@ private static async Task ConfirmDestroyAsync(PipelineStepContext context, strin { var interactionService = context.Services.GetRequiredService(); - if (interactionService.IsAvailable) + if (!interactionService.IsAvailable) { - var result = await interactionService.PromptNotificationAsync( - "Destroy Environment", - message, - new NotificationInteractionOptions - { - Intent = MessageIntent.Confirmation, - ShowSecondaryButton = true, - ShowDismiss = false, - PrimaryButtonText = "Yes, destroy", - SecondaryButtonText = "Cancel" - }, - context.CancellationToken).ConfigureAwait(false); + throw new InvalidOperationException( + "Cannot perform destructive operation without confirmation. Use --yes to skip the confirmation prompt in non-interactive mode."); + } - if (result.Canceled || !result.Data) + var result = await interactionService.PromptNotificationAsync( + "Destroy Environment", + message, + new NotificationInteractionOptions { - context.Logger.LogInformation("User canceled the destroy operation."); - throw new OperationCanceledException("Destroy operation canceled by user."); - } + Intent = MessageIntent.Confirmation, + ShowSecondaryButton = true, + ShowDismiss = false, + PrimaryButtonText = "Yes, destroy", + SecondaryButtonText = "Cancel" + }, + context.CancellationToken).ConfigureAwait(false); + + if (result.Canceled || !result.Data) + { + context.Logger.LogInformation("User canceled the destroy operation."); + throw new OperationCanceledException("Destroy operation canceled by user."); } } } From 364bb12c4c9568f2917c1eace8ca3f696e84ee91 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 12:12:56 -0700 Subject: [PATCH 08/35] Clear deployment state after successful destroy The destroy aggregation step now deletes the deployment state file after all environment destroy steps succeed, acting as an implicit cache clear. This ensures the next deploy starts fresh. Removed per-section Azure state cleanup since the whole file is now deleted at the end. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AzureEnvironmentResource.cs | 7 +------ .../Pipelines/DistributedApplicationPipeline.cs | 13 ++++++++++++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index d7664578ba6..4fc907b79f5 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -238,9 +238,7 @@ await context.ReportingStep.CompleteAsync( } catch (global::Azure.RequestFailedException ex) when (ex.Status == 404) { - // Resource group already deleted — clean up state - await deploymentStateManager.DeleteSectionAsync(azureStateSection, context.CancellationToken).ConfigureAwait(false); - + // Resource group already deleted await context.ReportingStep.CompleteAsync( new MarkdownString($"Resource group **{resourceGroupName}** not found (already deleted)"), CompletionState.Completed, @@ -343,9 +341,6 @@ await discoveryTask.CompleteAsync( { await resourceGroup.DeleteAsync(global::Azure.WaitUntil.Started, context.CancellationToken).ConfigureAwait(false); - // Clean up deployment state after successful destroy initiation - await deploymentStateManager.DeleteSectionAsync(azureStateSection, context.CancellationToken).ConfigureAwait(false); - await deleteTask.CompleteAsync( new MarkdownString($"Resource group **{resourceGroupName}** deletion initiated successfully"), CompletionState.Completed, diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index 75f181a0e04..3c3bd5e6899 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -270,7 +270,18 @@ public DistributedApplicationPipeline() { Name = WellKnownPipelineSteps.Destroy, Description = "Aggregation step for all destroy operations. All destroy steps should be required by this step.", - Action = _ => Task.CompletedTask, + Action = context => + { + // Clear deployment state after successful destroy + var deploymentStateManager = context.Services.GetRequiredService(); + if (deploymentStateManager.StateFilePath is string stateFilePath && File.Exists(stateFilePath)) + { + File.Delete(stateFilePath); + context.Logger.LogInformation("Deployment state cleared: {Path}", stateFilePath); + } + + return Task.CompletedTask; + }, }); _steps.Add(new PipelineStep From 5840dfcd4370a91e0d8859943be42cd9c8f573c9 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 12:27:33 -0700 Subject: [PATCH 09/35] Address review: fail-fast non-interactive guard and state cleanup docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move non-interactive/--yes check before ARM calls in Azure destroy so it fails fast without doing expensive Azure work - Document that state file deletion is intentional (includes saved parameters — expected for full environment teardown) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AzureEnvironmentResource.cs | 19 ++++++++++++------- .../DistributedApplicationPipeline.cs | 5 ++++- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 4fc907b79f5..e765d3bc617 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -224,6 +224,18 @@ await context.ReportingStep.CompleteAsync( return; } + // Fail fast in non-interactive mode without --yes before doing any Azure work + var options = context.Services.GetRequiredService>(); + if (!options.Value.Yes) + { + var interactionService = context.Services.GetRequiredService(); + if (!interactionService.IsAvailable) + { + throw new InvalidOperationException( + "Cannot perform destructive operation without confirmation. Use --yes to skip the confirmation prompt in non-interactive mode."); + } + } + var credential = tokenCredentialProvider.TokenCredential; var armClient = armClientProvider.GetArmClient(credential, subscriptionId); var (subscription, _) = await armClient.GetSubscriptionAndTenantAsync(context.CancellationToken).ConfigureAwait(false); @@ -296,17 +308,10 @@ await discoveryTask.CompleteAsync( } // Confirm destruction with the user (unless --yes was specified) - var options = context.Services.GetRequiredService>(); if (!options.Value.Yes) { var interactionService = context.Services.GetRequiredService(); - if (!interactionService.IsAvailable) - { - throw new InvalidOperationException( - "Cannot perform destructive operation without confirmation. Use --yes to skip the confirmation prompt in non-interactive mode."); - } - var confirmMessage = resources.Count > 0 ? $"Delete resource group '{resourceGroupName}' with {resources.Count} resource(s)? This action cannot be undone." : $"Delete resource group '{resourceGroupName}'? This action cannot be undone."; diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index 3c3bd5e6899..e7e424560ef 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -272,7 +272,10 @@ public DistributedApplicationPipeline() Description = "Aggregation step for all destroy operations. All destroy steps should be required by this step.", Action = context => { - // Clear deployment state after successful destroy + // Clear all deployment state after successful destroy. + // This includes parameter values — users will need to re-enter + // them on the next deploy, which is the expected behavior after + // a full environment teardown. var deploymentStateManager = context.Services.GetRequiredService(); if (deploymentStateManager.StateFilePath is string stateFilePath && File.Exists(stateFilePath)) { From 03dea070fe2a921cae6e44660c967ddf6458d294 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 12:31:13 -0700 Subject: [PATCH 10/35] Improve error message when compose file not found during destroy Tell the user to pass the same --output-path they used during deploy, instead of just saying the file doesn't exist. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs index 231436b2153..e85b2f31a9a 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs @@ -273,7 +273,9 @@ private async Task DockerComposeDownAsync(PipelineStepContext context) if (!File.Exists(dockerComposeFilePath)) { - throw new InvalidOperationException($"Docker Compose file not found at {dockerComposeFilePath}"); + throw new InvalidOperationException( + $"Docker Compose file not found at '{dockerComposeFilePath}'. " + + $"If you deployed with a custom --output-path, pass the same path to the destroy command."); } var runtime = await context.Services.GetRequiredService().ResolveAsync(context.CancellationToken).ConfigureAwait(false); From 02134e9c50c926524c80e864f305a9e1f9137462 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 12:38:57 -0700 Subject: [PATCH 11/35] Surface stderr in compose down error messages Include the actual error output (e.g. 'Cannot connect to Docker daemon') instead of generic 'ensure runtime is installed' guidance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Publishing/ContainerRuntimeBase.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs index ac465ed9057..43fb9cdc9c7 100644 --- a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs +++ b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs @@ -354,6 +354,7 @@ public virtual async Task ComposeDownAsync(ComposeOperationContext context, Canc _logger.LogDebug("Running {Runtime} compose down with arguments: {Arguments}", RuntimeExecutable, arguments); + var stderrLines = new List(); var spec = new ProcessSpec(RuntimeExecutable) { Arguments = arguments, @@ -367,6 +368,10 @@ public virtual async Task ComposeDownAsync(ComposeOperationContext context, Canc OnErrorData = error => { _logger.LogDebug("{Runtime} compose down (stderr): {Error}", RuntimeExecutable, error); + if (!string.IsNullOrWhiteSpace(error)) + { + stderrLines.Add(error); + } }, }; @@ -380,9 +385,12 @@ public virtual async Task ComposeDownAsync(ComposeOperationContext context, Canc if (processResult.ExitCode != 0) { + var stderrOutput = stderrLines.Count > 0 + ? " " + string.Join(" ", stderrLines) + : ""; + throw new DistributedApplicationException( - $"'{RuntimeExecutable} compose down' failed with exit code {processResult.ExitCode}. " + - $"Ensure '{RuntimeExecutable}' is installed and available on PATH."); + $"'{RuntimeExecutable} compose down' failed with exit code {processResult.ExitCode}.{stderrOutput}"); } } } From 9f8e99c2f95c05bdd106f1175352f385ee1d3ace Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 12:45:52 -0700 Subject: [PATCH 12/35] Persist deployment state for Docker Compose and Helm Save minimal deployment state during deploy so destroy can verify what was actually deployed: Docker Compose: saves OutputPath, ProjectName, ComposeFilePath to DockerCompose:{name} state section during compose-up Helm: saves ReleaseName, Namespace to Helm:{name} state section during helm-deploy Destroy steps now check for deployment state first and report 'Nothing to destroy' instead of failing with confusing errors when no deployment exists. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DockerComposeEnvironmentResource.cs | 23 +++++++++++++++ .../Deployment/HelmDeploymentEngine.cs | 29 +++++++++++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs index e85b2f31a9a..6d94083854d 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. #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 ASPIREPIPELINES002 #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. #pragma warning disable ASPIRECONTAINERRUNTIME001 #pragma warning disable ASPIREINTERACTION001 @@ -130,6 +131,20 @@ public DockerComposeEnvironmentResource(string name) : base(name) Description = $"Confirms and destroys the Docker Compose environment {Name}.", Action = async ctx => { + // Check deployment state to verify this environment was actually deployed + var deploymentStateManager = ctx.Services.GetRequiredService(); + var stateSection = await deploymentStateManager.AcquireSectionAsync($"DockerCompose:{Name}", ctx.CancellationToken).ConfigureAwait(false); + var savedComposeFilePath = stateSection.Data["ComposeFilePath"]?.ToString(); + + if (string.IsNullOrEmpty(savedComposeFilePath)) + { + await ctx.ReportingStep.CompleteAsync( + $"No Docker Compose deployment state found for '{Name}'. Nothing to destroy.", + CompletionState.Completed, + ctx.CancellationToken).ConfigureAwait(false); + return; + } + await ConfirmDestroyAsync(ctx, $"Shut down Docker Compose environment '{Name}'? This will stop and remove all containers, networks, and volumes.").ConfigureAwait(false); await DockerComposeDownAsync(ctx).ConfigureAwait(false); }, @@ -253,6 +268,14 @@ private async Task DockerComposeUpAsync(PipelineStepContext context) await runtime.ComposeUpAsync(composeContext, context.CancellationToken).ConfigureAwait(false); + // Persist deployment state so destroy can find the compose file and project name + var deploymentStateManager = context.Services.GetRequiredService(); + var stateSection = await deploymentStateManager.AcquireSectionAsync($"DockerCompose:{Name}", context.CancellationToken).ConfigureAwait(false); + stateSection.Data["OutputPath"] = outputPath; + stateSection.Data["ProjectName"] = composeContext.ProjectName; + stateSection.Data["ComposeFilePath"] = composeContext.ComposeFilePath; + await deploymentStateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); + await deployTask.CompleteAsync( new MarkdownString($"Service **{Name}** is now running with Docker Compose locally (runtime: {runtime.Name})"), CompletionState.Completed, diff --git a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs index e6ce1ffd3e1..c357610d07b 100644 --- a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs +++ b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. #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 ASPIREPIPELINES002 #pragma warning disable ASPIREINTERACTION001 using System.Globalization; @@ -161,9 +162,24 @@ internal static Task> CreateStepsAsync( Description = $"Confirms and destroys the Helm deployment for {environment.Name}.", Action = async ctx => { - var @namespace = await ResolveNamespaceAsync(ctx, environment).ConfigureAwait(false); - var releaseName = await ResolveReleaseNameAsync(ctx, environment).ConfigureAwait(false); - await ConfirmDestroyAsync(ctx, $"Uninstall Helm release '{releaseName}' from namespace '{@namespace}'? This action cannot be undone.").ConfigureAwait(false); + // Check deployment state to verify this environment was actually deployed + var deploymentStateManager = ctx.Services.GetRequiredService(); + var stateSection = await deploymentStateManager.AcquireSectionAsync($"Helm:{environment.Name}", ctx.CancellationToken).ConfigureAwait(false); + var savedReleaseName = stateSection.Data["ReleaseName"]?.ToString(); + var savedNamespace = stateSection.Data["Namespace"]?.ToString(); + + if (string.IsNullOrEmpty(savedReleaseName)) + { + await ctx.ReportingStep.CompleteAsync( + $"No Helm deployment state found for '{environment.Name}'. Nothing to destroy.", + CompletionState.Completed, + ctx.CancellationToken).ConfigureAwait(false); + return; + } + + // Use saved state for the confirmation message (more accurate than recomputing) + var @namespace = savedNamespace ?? "default"; + await ConfirmDestroyAsync(ctx, $"Uninstall Helm release '{savedReleaseName}' from namespace '{@namespace}'? This action cannot be undone.").ConfigureAwait(false); await HelmUninstallAsync(ctx, environment).ConfigureAwait(false); }, DependsOnSteps = [WellKnownPipelineSteps.DestroyPrereq] @@ -444,6 +460,13 @@ private static async Task HelmDeployAsync(PipelineStepContext context, Kubernete } else { + // Persist deployment state so destroy can find the release + var deploymentStateManager = context.Services.GetRequiredService(); + var stateSection = await deploymentStateManager.AcquireSectionAsync($"Helm:{environment.Name}", context.CancellationToken).ConfigureAwait(false); + stateSection.Data["ReleaseName"] = releaseName; + stateSection.Data["Namespace"] = @namespace; + await deploymentStateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); + await deployTask.CompleteAsync( new MarkdownString($"Helm release **{releaseName}** deployed to namespace **{@namespace}**"), CompletionState.Completed, From c4699266a0018df218afa813affe8146f55a26a7 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 13:24:47 -0700 Subject: [PATCH 13/35] Address manual CR feedback 1. Merge Azure destroy step into existing PipelineStepAnnotation 2. Flatten await using blocks to reduce nesting 3. Remove global::Azure prefix (using Azure; works fine) 4. Add IDeploymentStateManager.ClearAllStateAsync for centralized cleanup 5. Per-environment destroy steps now clean up their own state sections 6. Extract shared AspireDestroyAsync helper for E2E test cleanup, removing duplication across 17 test files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AzureEnvironmentResource.cs | 107 ++++++++---------- .../DockerComposeEnvironmentResource.cs | 14 ++- .../Deployment/HelmDeploymentEngine.cs | 3 + .../DistributedApplicationPipeline.cs | 16 +-- .../Pipelines/IDeploymentStateManager.cs | 6 + .../Internal/DeploymentStateManagerBase.cs | 20 ++++ .../DockerDeploymentTests.cs | 11 +- .../Helpers/CliE2EAutomatorHelpers.cs | 16 +++ .../KubernetesPublishTests.cs | 6 +- .../PodmanDeploymentTests.cs | 6 +- .../AcaCompactNamingDeploymentTests.cs | 5 +- .../AcaCompactNamingUpgradeDeploymentTests.cs | 5 +- .../AksStarterDeploymentTests.cs | 6 +- .../AksStarterWithRedisDeploymentTests.cs | 6 +- .../AzureAppConfigDeploymentTests.cs | 5 +- .../AzureContainerRegistryDeploymentTests.cs | 5 +- .../AzureEventHubsDeploymentTests.cs | 5 +- .../AzureKeyVaultDeploymentTests.cs | 5 +- .../AzureLogAnalyticsDeploymentTests.cs | 5 +- .../AzureServiceBusDeploymentTests.cs | 5 +- .../AzureStorageDeploymentTests.cs | 5 +- .../Helpers/DeploymentE2EAutomatorHelpers.cs | 16 +++ .../VnetKeyVaultInfraDeploymentTests.cs | 5 +- .../VnetSqlServerInfraDeploymentTests.cs | 5 +- .../VnetStorageBlobInfraDeploymentTests.cs | 5 +- 25 files changed, 143 insertions(+), 150 deletions(-) diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index e765d3bc617..21dbde34443 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -11,6 +11,7 @@ using Aspire.Hosting.Azure.Provisioning; using Aspire.Hosting.Azure.Provisioning.Internal; using Aspire.Hosting.Pipelines; +using Azure; using Azure.Core; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -117,12 +118,6 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet provisionStep.DependsOn(createContextStep); - return [publishStep, validateStep, createContextStep, provisionStep]; - })); - - // Add destroy step for tearing down Azure resources - Annotations.Add(new PipelineStepAnnotation((factoryContext) => - { var destroyStep = new PipelineStep { Name = $"destroy-azure-{Name}", @@ -132,7 +127,7 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet DependsOnSteps = [WellKnownPipelineSteps.DestroyPrereq] }; - return [destroyStep]; + return [publishStep, validateStep, createContextStep, provisionStep, destroyStep]; })); Annotations.Add(ManifestPublishingCallbackAnnotation.Ignore); @@ -248,7 +243,7 @@ await context.ReportingStep.CompleteAsync( var rgResponse = await resourceGroups.GetAsync(resourceGroupName, context.CancellationToken).ConfigureAwait(false); resourceGroup = rgResponse.Value; } - catch (global::Azure.RequestFailedException ex) when (ex.Status == 404) + catch (RequestFailedException ex) when (ex.Status == 404) { // Resource group already deleted await context.ReportingStep.CompleteAsync( @@ -264,48 +259,47 @@ await context.ReportingStep.CompleteAsync( context.CancellationToken).ConfigureAwait(false); var resources = new List<(string Name, string ResourceType)>(); - await using (discoveryTask.ConfigureAwait(false)) + await using var _ = discoveryTask.ConfigureAwait(false); + + try { - try + await foreach (var resource in resourceGroup.GetResourcesAsync(context.CancellationToken).ConfigureAwait(false)) { - await foreach (var resource in resourceGroup.GetResourcesAsync(context.CancellationToken).ConfigureAwait(false)) - { - resources.Add(resource); - } + resources.Add(resource); + } - if (resources.Count == 0) - { - await discoveryTask.CompleteAsync( - new MarkdownString($"Resource group **{resourceGroupName}** is empty"), - CompletionState.Completed, - context.CancellationToken).ConfigureAwait(false); - } - else - { - foreach (var (name, type) in resources) - { - var shortType = type.StartsWith("Microsoft.", StringComparison.OrdinalIgnoreCase) - ? type["Microsoft.".Length..] - : type; - context.Logger.LogInformation(" {Type}: {Name}", shortType, name); - } - - await discoveryTask.CompleteAsync( - new MarkdownString($"Found **{resources.Count}** resource(s) in **{resourceGroupName}**"), - CompletionState.Completed, - context.CancellationToken).ConfigureAwait(false); - } + if (resources.Count == 0) + { + await discoveryTask.CompleteAsync( + new MarkdownString($"Resource group **{resourceGroupName}** is empty"), + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); } - catch (Exception ex) + else { - // Non-fatal — proceed with deletion even if enumeration fails - context.Logger.LogWarning(ex, "Failed to enumerate resources in resource group '{ResourceGroupName}'", resourceGroupName); + foreach (var (name, type) in resources) + { + var shortType = type.StartsWith("Microsoft.", StringComparison.OrdinalIgnoreCase) + ? type["Microsoft.".Length..] + : type; + context.Logger.LogInformation(" {Type}: {Name}", shortType, name); + } + await discoveryTask.CompleteAsync( - "Could not enumerate resources (will proceed with deletion)", + new MarkdownString($"Found **{resources.Count}** resource(s) in **{resourceGroupName}**"), CompletionState.Completed, context.CancellationToken).ConfigureAwait(false); } } + catch (Exception ex) + { + // Non-fatal — proceed with deletion even if enumeration fails + context.Logger.LogWarning(ex, "Failed to enumerate resources in resource group '{ResourceGroupName}'", resourceGroupName); + await discoveryTask.CompleteAsync( + "Could not enumerate resources (will proceed with deletion)", + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); + } // Confirm destruction with the user (unless --yes was specified) if (!options.Value.Yes) @@ -340,25 +334,24 @@ await discoveryTask.CompleteAsync( var deleteTask = await context.ReportingStep.CreateTaskAsync( new MarkdownString($"Deleting resource group **{resourceGroupName}** ({resources.Count} resource(s))"), context.CancellationToken).ConfigureAwait(false); - await using (deleteTask.ConfigureAwait(false)) + await using var __ = deleteTask.ConfigureAwait(false); + + try { - try - { - await resourceGroup.DeleteAsync(global::Azure.WaitUntil.Started, context.CancellationToken).ConfigureAwait(false); + await resourceGroup.DeleteAsync(WaitUntil.Started, context.CancellationToken).ConfigureAwait(false); - await deleteTask.CompleteAsync( - new MarkdownString($"Resource group **{resourceGroupName}** deletion initiated successfully"), - CompletionState.Completed, - context.CancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - await deleteTask.CompleteAsync( - $"Failed to delete resource group '{resourceGroupName}': {ex.Message}", - CompletionState.CompletedWithError, - context.CancellationToken).ConfigureAwait(false); - throw; - } + await deleteTask.CompleteAsync( + new MarkdownString($"Resource group **{resourceGroupName}** deletion initiated successfully"), + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + await deleteTask.CompleteAsync( + $"Failed to delete resource group '{resourceGroupName}': {ex.Message}", + CompletionState.CompletedWithError, + context.CancellationToken).ConfigureAwait(false); + throw; } } } diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs index 6d94083854d..41e5bd336b2 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs @@ -146,7 +146,19 @@ await ctx.ReportingStep.CompleteAsync( } await ConfirmDestroyAsync(ctx, $"Shut down Docker Compose environment '{Name}'? This will stop and remove all containers, networks, and volumes.").ConfigureAwait(false); - await DockerComposeDownAsync(ctx).ConfigureAwait(false); + + // If the compose file no longer exists, treat as already cleaned up + if (File.Exists(savedComposeFilePath)) + { + await DockerComposeDownAsync(ctx).ConfigureAwait(false); + } + else + { + ctx.Logger.LogInformation("Compose file '{Path}' no longer exists, skipping compose down.", savedComposeFilePath); + } + + // Clean up deployment state for this environment + await deploymentStateManager.DeleteSectionAsync(stateSection, ctx.CancellationToken).ConfigureAwait(false); }, DependsOnSteps = [WellKnownPipelineSteps.DestroyPrereq] }; diff --git a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs index c357610d07b..d0401df3cda 100644 --- a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs +++ b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs @@ -181,6 +181,9 @@ await ctx.ReportingStep.CompleteAsync( var @namespace = savedNamespace ?? "default"; await ConfirmDestroyAsync(ctx, $"Uninstall Helm release '{savedReleaseName}' from namespace '{@namespace}'? This action cannot be undone.").ConfigureAwait(false); await HelmUninstallAsync(ctx, environment).ConfigureAwait(false); + + // Clean up deployment state for this environment + await deploymentStateManager.DeleteSectionAsync(stateSection, ctx.CancellationToken).ConfigureAwait(false); }, DependsOnSteps = [WellKnownPipelineSteps.DestroyPrereq] }; diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index e7e424560ef..75f181a0e04 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -270,21 +270,7 @@ public DistributedApplicationPipeline() { Name = WellKnownPipelineSteps.Destroy, Description = "Aggregation step for all destroy operations. All destroy steps should be required by this step.", - Action = context => - { - // Clear all deployment state after successful destroy. - // This includes parameter values — users will need to re-enter - // them on the next deploy, which is the expected behavior after - // a full environment teardown. - var deploymentStateManager = context.Services.GetRequiredService(); - if (deploymentStateManager.StateFilePath is string stateFilePath && File.Exists(stateFilePath)) - { - File.Delete(stateFilePath); - context.Logger.LogInformation("Deployment state cleared: {Path}", stateFilePath); - } - - return Task.CompletedTask; - }, + Action = _ => Task.CompletedTask, }); _steps.Add(new PipelineStep diff --git a/src/Aspire.Hosting/Pipelines/IDeploymentStateManager.cs b/src/Aspire.Hosting/Pipelines/IDeploymentStateManager.cs index d52666ada71..c404f6c1f7e 100644 --- a/src/Aspire.Hosting/Pipelines/IDeploymentStateManager.cs +++ b/src/Aspire.Hosting/Pipelines/IDeploymentStateManager.cs @@ -42,4 +42,10 @@ public interface IDeploymentStateManager /// The cancellation token. /// Thrown when a version conflict is detected, indicating the section was modified after it was acquired. Task DeleteSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken = default); + + /// + /// Clears all deployment state, removing all sections and the underlying storage. + /// + /// The cancellation token. + Task ClearAllStateAsync(CancellationToken cancellationToken = default); } diff --git a/src/Aspire.Hosting/Pipelines/Internal/DeploymentStateManagerBase.cs b/src/Aspire.Hosting/Pipelines/Internal/DeploymentStateManagerBase.cs index 2d242c2bb6a..5d40834115f 100644 --- a/src/Aspire.Hosting/Pipelines/Internal/DeploymentStateManagerBase.cs +++ b/src/Aspire.Hosting/Pipelines/Internal/DeploymentStateManagerBase.cs @@ -307,4 +307,24 @@ private static void SetNestedPropertyValue(JsonObject root, string path, JsonObj current[segments[^1]] = value; } } + + /// + public Task ClearAllStateAsync(CancellationToken cancellationToken = default) + { + if (StateFilePath is string stateFilePath && File.Exists(stateFilePath)) + { + File.Delete(stateFilePath); + logger.LogInformation("Deployment state cleared: {Path}", stateFilePath); + } + + // Reset in-memory state + lock (_sectionsLock) + { + _sections.Clear(); + } + _state = null; + _isStateLoaded = false; + + return Task.CompletedTask; + } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs index d47ab506da0..942458ca9e2 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.EndToEnd.Tests.Helpers; -using Aspire.Cli.Resources; using Aspire.Cli.Tests.Utils; using Aspire.TestUtilities; using Hex1b.Automation; @@ -132,10 +131,7 @@ public async Task CreateAndDeployToDockerCompose() await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); // Step 11: Clean up - destroy the deployment using aspire destroy - await auto.TypeAsync("aspire destroy --yes"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(1)); + await auto.AspireDestroyAsync(counter); await auto.TypeAsync("exit"); await auto.EnterAsync(); @@ -257,10 +253,7 @@ public async Task CreateAndDeployToDockerComposeInteractive() await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); // Step 11: Clean up - destroy the deployment using aspire destroy - await auto.TypeAsync("aspire destroy --yes"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(1)); + await auto.AspireDestroyAsync(counter); await auto.TypeAsync("exit"); await auto.EnterAsync(); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs index 17682bd3e35..2eda6e8cb96 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Xml.Linq; +using Aspire.Cli.Resources; using Aspire.Cli.Tests.Utils; using Hex1b.Automation; @@ -465,4 +466,19 @@ internal static async Task CaptureAspireDiagnosticsAsync( await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); } + + /// + /// Destroys the current deployment using aspire destroy --yes and waits for pipeline success. + /// + internal static async Task AspireDestroyAsync( + this Hex1bTerminalAutomator auto, + SequenceCounter counter, + TimeSpan? timeout = null) + { + timeout ??= TimeSpan.FromMinutes(2); + await auto.TypeAsync("aspire destroy --yes"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: timeout.Value); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(1)); + } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs index 90082079ff0..6e81667b57e 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.EndToEnd.Tests.Helpers; -using Aspire.Cli.Resources; using Aspire.Cli.Tests.Utils; using Aspire.TestUtilities; using Hex1b.Automation; @@ -295,10 +294,7 @@ await auto.TypeAsync("helm install aspire-app helm-output " + // ===================================================================== // Destroy the deployment using aspire destroy (runs helm uninstall) - await auto.TypeAsync("aspire destroy --yes"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(1)); + await auto.AspireDestroyAsync(counter); // Delete the KinD cluster await auto.TypeAsync($"kind delete cluster --name={clusterName}"); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs index d86ec1ee10d..d88c7b147ae 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.EndToEnd.Tests.Helpers; -using Aspire.Cli.Resources; using Aspire.Cli.Tests.Utils; using Aspire.TestUtilities; using Hex1b.Automation; @@ -126,10 +125,7 @@ public async Task CreateAndDeployToDockerComposeWithPodman() await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); // Step 11: Clean up - destroy the deployment using aspire destroy - await auto.TypeAsync("aspire destroy --yes"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(1)); + await auto.AspireDestroyAsync(counter); await auto.TypeAsync("exit"); await auto.EnterAsync(); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs index f030678204b..d74503fcd14 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs @@ -154,10 +154,7 @@ await auto.TypeAsync( // Step 9: Clean up Azure resources using aspire destroy output.WriteLine("Step 9: Destroying Azure deployment..."); - await auto.TypeAsync("aspire destroy --yes"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + await auto.AspireDestroyAsync(counter); // Step 10: Exit await auto.TypeAsync("exit"); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs index dfdef1be3d2..b851b60a766 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs @@ -363,10 +363,7 @@ await auto.TypeAsync( // Step 15: Clean up Azure resources using aspire destroy output.WriteLine("Step 15: Destroying Azure deployment..."); - await auto.TypeAsync("aspire destroy --yes"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + await auto.AspireDestroyAsync(counter); // Step 16: Exit await auto.TypeAsync("exit"); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs index d6a8526c4cf..93c07afc369 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.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 Aspire.Cli.Resources; using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; using Hex1b.Automation; @@ -331,10 +330,7 @@ await auto.TypeAsync($"dotnet publish {projectName}.ApiService/{projectName}.Api // Step 28: Clean up Azure resources using aspire destroy output.WriteLine("Step 28: Destroying Azure deployment..."); - await auto.TypeAsync("aspire destroy --yes"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + await auto.AspireDestroyAsync(counter); // Step 29: Exit terminal await auto.TypeAsync("exit"); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs index 285eae54070..c1e72acf511 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.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 Aspire.Cli.Resources; using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; using Hex1b.Automation; @@ -360,10 +359,7 @@ await auto.TypeAsync($"dotnet publish {projectName}.ApiService/{projectName}.Api // Step 30: Clean up Azure resources using aspire destroy output.WriteLine("Step 30: Destroying Azure deployment..."); - await auto.TypeAsync("aspire destroy --yes"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + await auto.AspireDestroyAsync(counter); // Step 31: Exit terminal await auto.TypeAsync("exit"); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs index 75e113163a9..e0e3190082a 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs @@ -160,10 +160,7 @@ private async Task DeployAzureAppConfigResourceCore(CancellationToken cancellati // Step 9: Clean up Azure resources using aspire destroy output.WriteLine("Step 9: Destroying Azure deployment..."); - await auto.TypeAsync("aspire destroy --yes"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + await auto.AspireDestroyAsync(counter); // Step 10: Exit terminal await auto.TypeAsync("exit"); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs index 01f4739ffd8..1d5fd8f5675 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs @@ -135,10 +135,7 @@ private async Task DeployAzureContainerRegistryResourceCore(CancellationToken ca // Step 9: Clean up Azure resources using aspire destroy output.WriteLine("Step 9: Destroying Azure deployment..."); - await auto.TypeAsync("aspire destroy --yes"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + await auto.AspireDestroyAsync(counter); // Step 10: Exit terminal await auto.TypeAsync("exit"); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs index 1b06d908eeb..e3185bf3f70 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs @@ -160,10 +160,7 @@ private async Task DeployAzureEventHubsResourceCore(CancellationToken cancellati // Step 9: Clean up Azure resources using aspire destroy output.WriteLine("Step 9: Destroying Azure deployment..."); - await auto.TypeAsync("aspire destroy --yes"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + await auto.AspireDestroyAsync(counter); // Step 10: Exit terminal await auto.TypeAsync("exit"); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs index 459284003cc..c82f20c54a7 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs @@ -160,10 +160,7 @@ private async Task DeployAzureKeyVaultResourceCore(CancellationToken cancellatio // Step 9: Clean up Azure resources using aspire destroy output.WriteLine("Step 9: Destroying Azure deployment..."); - await auto.TypeAsync("aspire destroy --yes"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + await auto.AspireDestroyAsync(counter); // Step 10: Exit terminal await auto.TypeAsync("exit"); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs index 931f73e8ba1..3452517147a 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs @@ -135,10 +135,7 @@ private async Task DeployAzureLogAnalyticsResourceCore(CancellationToken cancell // Step 9: Clean up Azure resources using aspire destroy output.WriteLine("Step 9: Destroying Azure deployment..."); - await auto.TypeAsync("aspire destroy --yes"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + await auto.AspireDestroyAsync(counter); // Step 10: Exit terminal await auto.TypeAsync("exit"); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs index 7fd8dbb60ef..2ac4d50c374 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs @@ -162,10 +162,7 @@ private async Task DeployAzureServiceBusResourceCore(CancellationToken cancellat // Step 9: Clean up Azure resources using aspire destroy output.WriteLine("Step 9: Destroying Azure deployment..."); - await auto.TypeAsync("aspire destroy --yes"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + await auto.AspireDestroyAsync(counter); // Step 10: Exit terminal await auto.TypeAsync("exit"); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs index d2a2eabc765..24d48087847 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs @@ -165,10 +165,7 @@ private async Task DeployAzureStorageResourceCore(CancellationToken cancellation // Step 9: Clean up Azure resources using aspire destroy output.WriteLine("Step 9: Destroying Azure deployment..."); - await auto.TypeAsync("aspire destroy --yes"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + await auto.AspireDestroyAsync(counter); // Step 10: Exit terminal await auto.TypeAsync("exit"); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2EAutomatorHelpers.cs b/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2EAutomatorHelpers.cs index 85fa9c741f4..2c0d5217538 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2EAutomatorHelpers.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2EAutomatorHelpers.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Cli.Resources; using Aspire.Cli.Tests.Utils; using Hex1b.Automation; @@ -111,4 +112,19 @@ internal static async Task SourceAspireBundleEnvironmentAsync( await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); } + + /// + /// Destroys the current deployment using aspire destroy --yes and waits for pipeline success. + /// + internal static async Task AspireDestroyAsync( + this Hex1bTerminalAutomator auto, + SequenceCounter counter, + TimeSpan? timeout = null) + { + timeout ??= TimeSpan.FromMinutes(5); + await auto.TypeAsync("aspire destroy --yes"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync(Aspire.Cli.Resources.ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: timeout.Value); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + } } diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultInfraDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultInfraDeploymentTests.cs index 8b06673622d..e48246dd4d7 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultInfraDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultInfraDeploymentTests.cs @@ -175,10 +175,7 @@ await auto.TypeAsync($"az network vnet list -g \"{resourceGroupName}\" --query \ // Step 9: Clean up Azure resources using aspire destroy output.WriteLine("Step 9: Destroying Azure deployment..."); - await auto.TypeAsync("aspire destroy --yes"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + await auto.AspireDestroyAsync(counter); // Step 10: Exit terminal await auto.TypeAsync("exit"); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerInfraDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerInfraDeploymentTests.cs index 7f589131061..8cb00bc3196 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerInfraDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerInfraDeploymentTests.cs @@ -176,10 +176,7 @@ await auto.TypeAsync($"az network vnet list -g \"{resourceGroupName}\" --query \ // Step 9: Clean up Azure resources using aspire destroy output.WriteLine("Step 9: Destroying Azure deployment..."); - await auto.TypeAsync("aspire destroy --yes"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + await auto.AspireDestroyAsync(counter); // Step 10: Exit terminal await auto.TypeAsync("exit"); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobInfraDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobInfraDeploymentTests.cs index 584e66de8ea..1c9c650599a 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobInfraDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobInfraDeploymentTests.cs @@ -177,10 +177,7 @@ await auto.TypeAsync($"az network vnet list -g \"{resourceGroupName}\" --query \ // Step 9: Clean up Azure resources using aspire destroy output.WriteLine("Step 9: Destroying Azure deployment..."); - await auto.TypeAsync("aspire destroy --yes"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(5)); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + await auto.AspireDestroyAsync(counter); // Step 10: Exit terminal await auto.TypeAsync("exit"); From d8c189216a96386b26ff9a6215ed2503a60823d3 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 13:28:46 -0700 Subject: [PATCH 14/35] Clear all deployment state on full destroy via ClearAllStateAsync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full destroy is a full reset — clears parameters, Azure config, and per-environment state. Per-section cleanup in environment steps handles partial/scoped operations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Pipelines/DistributedApplicationPipeline.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index 75f181a0e04..2461b82df51 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -270,7 +270,13 @@ public DistributedApplicationPipeline() { Name = WellKnownPipelineSteps.Destroy, Description = "Aggregation step for all destroy operations. All destroy steps should be required by this step.", - Action = _ => Task.CompletedTask, + Action = async context => + { + // Full destroy clears all deployment state — parameters, Azure config, everything. + // The next deploy starts completely fresh. + var deploymentStateManager = context.Services.GetRequiredService(); + await deploymentStateManager.ClearAllStateAsync(context.CancellationToken).ConfigureAwait(false); + }, }); _steps.Add(new PipelineStep From 3adc71ef2b4b4803caafc0a9ff73e7a29c1867d4 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 13:30:24 -0700 Subject: [PATCH 15/35] Add deployment summary to destroy output Each environment now adds summary entries showing what was destroyed: - Azure: resource group name + subscription - Docker Compose: environment name - Helm: release name + namespace Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs | 3 +++ src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs | 2 ++ .../Deployment/HelmDeploymentEngine.cs | 3 +++ 3 files changed, 8 insertions(+) diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 21dbde34443..9eca7fb5495 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -340,6 +340,9 @@ await discoveryTask.CompleteAsync( { await resourceGroup.DeleteAsync(WaitUntil.Started, context.CancellationToken).ConfigureAwait(false); + context.Summary.Add("🗑️ Resource Group", resourceGroupName); + context.Summary.Add("🔑 Subscription", subscriptionId); + await deleteTask.CompleteAsync( new MarkdownString($"Resource group **{resourceGroupName}** deletion initiated successfully"), CompletionState.Completed, diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs index 41e5bd336b2..7bcfc6155b9 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs @@ -157,6 +157,8 @@ await ctx.ReportingStep.CompleteAsync( ctx.Logger.LogInformation("Compose file '{Path}' no longer exists, skipping compose down.", savedComposeFilePath); } + ctx.Summary.Add("🗑️ Compose", Name); + // Clean up deployment state for this environment await deploymentStateManager.DeleteSectionAsync(stateSection, ctx.CancellationToken).ConfigureAwait(false); }, diff --git a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs index d0401df3cda..9d3ccbc4e0a 100644 --- a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs +++ b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs @@ -182,6 +182,9 @@ await ctx.ReportingStep.CompleteAsync( await ConfirmDestroyAsync(ctx, $"Uninstall Helm release '{savedReleaseName}' from namespace '{@namespace}'? This action cannot be undone.").ConfigureAwait(false); await HelmUninstallAsync(ctx, environment).ConfigureAwait(false); + ctx.Summary.Add("🗑️ Helm Release", savedReleaseName); + ctx.Summary.Add("☸️ Namespace", @namespace); + // Clean up deployment state for this environment await deploymentStateManager.DeleteSectionAsync(stateSection, ctx.CancellationToken).ConfigureAwait(false); }, From 9ece78376409603a36219c206b343815481425c1 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 13:47:41 -0700 Subject: [PATCH 16/35] Use ClearAllStateAsync for --clear-cache instead of raw File.Delete Consolidate all state file mutations through IDeploymentStateManager so in-memory state is also reset correctly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index 2461b82df51..7af228f283c 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -108,7 +108,7 @@ public DistributedApplicationPipeline() // User confirmed - delete the deployment state file context.Logger.LogInformation("Deleting deployment state file at {Path} due to --clear-cache flag", stateFilePath); - File.Delete(stateFilePath); + await deploymentStateManager.ClearAllStateAsync(context.CancellationToken).ConfigureAwait(false); } } } From 23cd951ee94ade6361af813e4eb2536ac2452aa6 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 14:20:04 -0700 Subject: [PATCH 17/35] Add Azure destroy unit tests with mockable state manager Three new tests covering the destroy pipeline for Azure: - WithAzureState: verifies RG discovery and deletion runs - WithNoAzureState: verifies 'Nothing to destroy' message - NonInteractiveWithoutYes: verifies fail-fast with --yes guidance Added InMemoryDeploymentStateManager for stateful test scenarios. Added deploymentStateManager parameter to ConfigureTestServices. Updated all IDeploymentStateManager mocks with ClearAllStateAsync. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AzureBicepProvisionerTests.cs | 2 + .../AzureDeployerTests.cs | 127 +++++++++++++++++- .../ProvisioningTestHelpers.cs | 2 + .../ApplicationOrchestratorTests.cs | 2 + .../Orchestrator/ParameterProcessorTests.cs | 4 + 5 files changed, 136 insertions(+), 1 deletion(-) diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureBicepProvisionerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureBicepProvisionerTests.cs index 59b4bc9a822..775e647a5f8 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureBicepProvisionerTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureBicepProvisionerTests.cs @@ -269,5 +269,7 @@ public Task SaveSectionAsync(DeploymentStateSection section, CancellationToken c { return Task.CompletedTask; } + + public Task ClearAllStateAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; } } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs index 0ef66685757..4b82721a981 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs @@ -1260,6 +1260,7 @@ private void ConfigureTestServices(IDistributedApplicationTestingBuilder builder MockProcessRunner? processRunner = null, IPipelineActivityReporter? activityReporter = null, IContainerRuntime? containerRuntime = null, + IDeploymentStateManager? deploymentStateManager = null, bool setDefaultProvisioningOptions = true) { var options = setDefaultProvisioningOptions ? ProvisioningTestHelpers.CreateOptions() : ProvisioningTestHelpers.CreateOptions(null, null, null); @@ -1284,7 +1285,7 @@ private void ConfigureTestServices(IDistributedApplicationTestingBuilder builder builder.Services.AddSingleton(activityReporter); } builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(deploymentStateManager ?? new NoOpDeploymentStateManager()); if (bicepProvisioner is not null) { builder.Services.AddSingleton(bicepProvisioner); @@ -1307,6 +1308,8 @@ public Task AcquireSectionAsync(string sectionName, Canc public Task DeleteSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task SaveSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task ClearAllStateAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; } private sealed class NoOpBicepProvisioner : IBicepProvisioner @@ -1734,4 +1737,126 @@ private static void ConfigureTestServicesWithFileDeploymentStateManager( builder.Services.AddSingleton(sp => (IContainerRuntimeResolver)sp.GetRequiredService()); builder.Services.AddSingleton(sp => new FakeAcrLoginService(sp.GetRequiredService())); } + + [Fact] + public async Task DestroyAsync_WithAzureState_DeletesResourceGroup() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: WellKnownPipelineSteps.Destroy); + var stateManager = new InMemoryDeploymentStateManager(); + stateManager.SetSection("Azure", new JsonObject + { + ["ResourceGroup"] = "rg-test-destroy", + ["SubscriptionId"] = "12345678-1234-1234-1234-123456789012", + ["Location"] = "westus2" + }); + + var mockActivityReporter = new TestPipelineActivityReporter(testOutputHelper); + var testInteractionService = new TestInteractionService(); + ConfigureTestServices(builder, interactionService: testInteractionService, bicepProvisioner: new NoOpBicepProvisioner(), activityReporter: mockActivityReporter, deploymentStateManager: stateManager, setDefaultProvisioningOptions: false); + builder.Services.Configure(o => o.Yes = true); + + builder.AddAzureContainerAppEnvironment("aca"); + builder.AddContainer("api", "myimage"); + + using var app = builder.Build(); + await app.RunAsync(); + + // Verify the destroy step ran successfully (check tasks, not step completion) + var createdSteps = mockActivityReporter.CreatedSteps; + Assert.Contains(createdSteps, s => s.Contains("destroy-azure-", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task DestroyAsync_WithNoAzureState_ReportsNothingToDestroy() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: WellKnownPipelineSteps.Destroy); + var stateManager = new InMemoryDeploymentStateManager(); + var mockActivityReporter = new TestPipelineActivityReporter(testOutputHelper); + + ConfigureTestServices(builder, bicepProvisioner: new NoOpBicepProvisioner(), activityReporter: mockActivityReporter, deploymentStateManager: stateManager, setDefaultProvisioningOptions: false); + builder.Services.Configure(o => o.Yes = true); + + builder.AddAzureContainerAppEnvironment("aca"); + builder.AddContainer("api", "myimage"); + + using var app = builder.Build(); + await app.RunAsync(); + + // Verify it reported nothing to destroy + var completedSteps = mockActivityReporter.CompletedSteps; + Assert.Contains(completedSteps, s => s.CompletionText.Contains("Nothing to destroy", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task DestroyAsync_NonInteractiveWithoutYes_FailsFast() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: WellKnownPipelineSteps.Destroy); + var stateManager = new InMemoryDeploymentStateManager(); + stateManager.SetSection("Azure", new JsonObject + { + ["ResourceGroup"] = "rg-test-destroy", + ["SubscriptionId"] = "12345678-1234-1234-1234-123456789012" + }); + + var mockActivityReporter = new TestPipelineActivityReporter(testOutputHelper); + // Non-interactive: interaction service with IsAvailable = false + var nonInteractiveService = new TestInteractionService { IsAvailable = false }; + ConfigureTestServices(builder, interactionService: nonInteractiveService, bicepProvisioner: new NoOpBicepProvisioner(), activityReporter: mockActivityReporter, deploymentStateManager: stateManager, setDefaultProvisioningOptions: false); + // Yes is NOT set — should fail fast + + builder.AddAzureContainerAppEnvironment("aca"); + builder.AddContainer("api", "myimage"); + + using var app = builder.Build(); + await app.RunAsync(); + + // The pipeline should have failed with a message about --yes + var completedSteps = mockActivityReporter.CompletedSteps; + Assert.Contains(completedSteps, s => s.CompletionText.Contains("--yes", StringComparison.OrdinalIgnoreCase)); + } + + private sealed class InMemoryDeploymentStateManager : IDeploymentStateManager + { + private readonly Dictionary _sections = new(); + private readonly Dictionary _versions = new(); + + public string? StateFilePath => null; + + public void SetSection(string name, JsonObject data) + { + _sections[name] = data; + _versions[name] = 1; + } + + public Task AcquireSectionAsync(string sectionName, CancellationToken cancellationToken = default) + { + if (_sections.TryGetValue(sectionName, out var data)) + { + var version = _versions.GetValueOrDefault(sectionName, 0); + return Task.FromResult(new DeploymentStateSection(sectionName, data, version)); + } + return Task.FromResult(new DeploymentStateSection(sectionName, [], 0)); + } + + public Task SaveSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken = default) + { + _sections[section.SectionName] = section.Data; + _versions[section.SectionName] = section.Version + 1; + return Task.CompletedTask; + } + + public Task DeleteSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken = default) + { + _sections.Remove(section.SectionName); + _versions.Remove(section.SectionName); + return Task.CompletedTask; + } + + public Task ClearAllStateAsync(CancellationToken cancellationToken = default) + { + _sections.Clear(); + _versions.Clear(); + return Task.CompletedTask; + } + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs b/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs index d09fe38a88d..0b531aa2fd1 100644 --- a/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs +++ b/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs @@ -640,6 +640,8 @@ public Task SaveSectionAsync(DeploymentStateSection section, CancellationToken c _state[section.SectionName] = section.Data; return Task.CompletedTask; } + + public Task ClearAllStateAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; } internal sealed class TestUserPrincipalProvider : IUserPrincipalProvider diff --git a/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs b/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs index f6d7dd6e4d5..0a113e2b19e 100644 --- a/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs +++ b/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs @@ -517,6 +517,8 @@ public Task SaveSectionAsync(DeploymentStateSection section, CancellationToken c return Task.CompletedTask; } + public Task ClearAllStateAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task DeleteSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken = default) { return Task.CompletedTask; diff --git a/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs b/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs index 6e97b8335f4..bb3801e50ec 100644 --- a/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs +++ b/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs @@ -1168,6 +1168,8 @@ public Task SaveSectionAsync(DeploymentStateSection section, CancellationToken c return Task.CompletedTask; } + public Task ClearAllStateAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task DeleteSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken = default) { return Task.CompletedTask; @@ -1602,6 +1604,8 @@ public Task SaveSectionAsync(DeploymentStateSection section, CancellationToken c return Task.CompletedTask; } + public Task ClearAllStateAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task DeleteSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken = default) { // Increment version to allow multiple saves with the same instance (mimics FileDeploymentStateManager) From 5c93ddd1f1f80322cbb56a5264d06d6dd50ce09c Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 14:32:55 -0700 Subject: [PATCH 18/35] Use persisted state for destroy operations and add Compose destroy tests Reliability fixes from code review: - Docker destroy now uses saved ComposeFilePath/ProjectName from deployment state instead of recomputing from current model - Helm destroy uses saved ReleaseName/Namespace for both the confirmation prompt and the actual uninstall call - Docker destroy only clears state after successful compose down, preserves state when compose file is missing Test improvements: - Extract InMemoryDeploymentStateManager to shared test code - Add FakeContainerRuntime.WasComposeDownCalled tracking - Add 2 Docker Compose destroy pipeline tests: - WithState: verifies compose down is called via FakeContainerRuntime - WithNoState: verifies 'Nothing to destroy' without calling compose down Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DockerComposeEnvironmentResource.cs | 38 ++++++++-- .../Deployment/HelmDeploymentEngine.cs | 6 +- .../Aspire.Hosting.Azure.Tests.csproj | 1 + .../AzureDeployerTests.cs | 45 ----------- .../Aspire.Hosting.Docker.Tests.csproj | 1 + .../DockerComposeTests.cs | 74 +++++++++++++++++++ .../Publishing/FakeContainerRuntime.cs | 2 + .../Shared/InMemoryDeploymentStateManager.cs | 57 ++++++++++++++ 8 files changed, 170 insertions(+), 54 deletions(-) create mode 100644 tests/Shared/InMemoryDeploymentStateManager.cs diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs index 7bcfc6155b9..759c6c016e7 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs @@ -147,20 +147,42 @@ await ctx.ReportingStep.CompleteAsync( await ConfirmDestroyAsync(ctx, $"Shut down Docker Compose environment '{Name}'? This will stop and remove all containers, networks, and volumes.").ConfigureAwait(false); - // If the compose file no longer exists, treat as already cleaned up + // Use saved state to build the compose context — don't recompute from current model if (File.Exists(savedComposeFilePath)) { - await DockerComposeDownAsync(ctx).ConfigureAwait(false); + var savedOutputPath = stateSection.Data["OutputPath"]?.ToString() ?? Path.GetDirectoryName(savedComposeFilePath)!; + var savedProjectName = stateSection.Data["ProjectName"]?.ToString() ?? GetDockerComposeProjectName(ctx, this); + + var runtime = await ctx.Services.GetRequiredService().ResolveAsync(ctx.CancellationToken).ConfigureAwait(false); + + var composeContext = new ComposeOperationContext + { + ComposeFilePath = savedComposeFilePath, + ProjectName = savedProjectName, + WorkingDirectory = savedOutputPath + }; + + var deployTask = await ctx.ReportingStep.CreateTaskAsync( + new MarkdownString($"Running compose down for **{Name}** using **{runtime.Name}**"), + ctx.CancellationToken).ConfigureAwait(false); + await using (deployTask.ConfigureAwait(false)) + { + await runtime.ComposeDownAsync(composeContext, ctx.CancellationToken).ConfigureAwait(false); + await deployTask.CompleteAsync( + new MarkdownString($"Compose shutdown complete for **{Name}** ({runtime.Name})"), + CompletionState.Completed, + ctx.CancellationToken).ConfigureAwait(false); + } + + ctx.Summary.Add("🗑️ Compose", Name); + + // Clean up deployment state only after successful teardown + await deploymentStateManager.DeleteSectionAsync(stateSection, ctx.CancellationToken).ConfigureAwait(false); } else { - ctx.Logger.LogInformation("Compose file '{Path}' no longer exists, skipping compose down.", savedComposeFilePath); + ctx.Logger.LogInformation("Compose file '{Path}' no longer exists, skipping compose down. State preserved for manual cleanup.", savedComposeFilePath); } - - ctx.Summary.Add("🗑️ Compose", Name); - - // Clean up deployment state for this environment - await deploymentStateManager.DeleteSectionAsync(stateSection, ctx.CancellationToken).ConfigureAwait(false); }, DependsOnSteps = [WellKnownPipelineSteps.DestroyPrereq] }; diff --git a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs index 9d3ccbc4e0a..ec62711d241 100644 --- a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs +++ b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs @@ -180,7 +180,7 @@ await ctx.ReportingStep.CompleteAsync( // Use saved state for the confirmation message (more accurate than recomputing) var @namespace = savedNamespace ?? "default"; await ConfirmDestroyAsync(ctx, $"Uninstall Helm release '{savedReleaseName}' from namespace '{@namespace}'? This action cannot be undone.").ConfigureAwait(false); - await HelmUninstallAsync(ctx, environment).ConfigureAwait(false); + await HelmUninstallAsync(ctx, savedReleaseName, @namespace).ConfigureAwait(false); ctx.Summary.Add("🗑️ Helm Release", savedReleaseName); ctx.Summary.Add("☸️ Namespace", @namespace); @@ -561,7 +561,11 @@ private static async Task HelmUninstallAsync(PipelineStepContext context, Kubern { var @namespace = await ResolveNamespaceAsync(context, environment).ConfigureAwait(false); var releaseName = await ResolveReleaseNameAsync(context, environment).ConfigureAwait(false); + await HelmUninstallAsync(context, releaseName, @namespace).ConfigureAwait(false); + } + private static async Task HelmUninstallAsync(PipelineStepContext context, string releaseName, string @namespace) + { var uninstallTask = await context.ReportingStep.CreateTaskAsync( new MarkdownString($"Uninstalling Helm release **{releaseName}** from namespace **{@namespace}**"), context.CancellationToken).ConfigureAwait(false); 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 79859deffbc..1330ab45bf4 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj +++ b/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj @@ -50,6 +50,7 @@ + diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs index 4b82721a981..33790043823 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs @@ -1814,49 +1814,4 @@ public async Task DestroyAsync_NonInteractiveWithoutYes_FailsFast() var completedSteps = mockActivityReporter.CompletedSteps; Assert.Contains(completedSteps, s => s.CompletionText.Contains("--yes", StringComparison.OrdinalIgnoreCase)); } - - private sealed class InMemoryDeploymentStateManager : IDeploymentStateManager - { - private readonly Dictionary _sections = new(); - private readonly Dictionary _versions = new(); - - public string? StateFilePath => null; - - public void SetSection(string name, JsonObject data) - { - _sections[name] = data; - _versions[name] = 1; - } - - public Task AcquireSectionAsync(string sectionName, CancellationToken cancellationToken = default) - { - if (_sections.TryGetValue(sectionName, out var data)) - { - var version = _versions.GetValueOrDefault(sectionName, 0); - return Task.FromResult(new DeploymentStateSection(sectionName, data, version)); - } - return Task.FromResult(new DeploymentStateSection(sectionName, [], 0)); - } - - public Task SaveSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken = default) - { - _sections[section.SectionName] = section.Data; - _versions[section.SectionName] = section.Version + 1; - return Task.CompletedTask; - } - - public Task DeleteSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken = default) - { - _sections.Remove(section.SectionName); - _versions.Remove(section.SectionName); - return Task.CompletedTask; - } - - public Task ClearAllStateAsync(CancellationToken cancellationToken = default) - { - _sections.Clear(); - _versions.Clear(); - return Task.CompletedTask; - } - } } diff --git a/tests/Aspire.Hosting.Docker.Tests/Aspire.Hosting.Docker.Tests.csproj b/tests/Aspire.Hosting.Docker.Tests/Aspire.Hosting.Docker.Tests.csproj index f14b6a2661f..28aaf565fc7 100644 --- a/tests/Aspire.Hosting.Docker.Tests/Aspire.Hosting.Docker.Tests.csproj +++ b/tests/Aspire.Hosting.Docker.Tests/Aspire.Hosting.Docker.Tests.csproj @@ -28,6 +28,7 @@ + diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs index 5577f29920a..240fd6a28e7 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs @@ -4,6 +4,7 @@ #pragma warning disable ASPIRECOMPUTE002 #pragma warning disable ASPIRECOMPUTE003 #pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES002 #pragma warning disable ASPIREPIPELINES003 #pragma warning disable ASPIRECONTAINERRUNTIME001 @@ -12,6 +13,7 @@ using Aspire.Hosting.Pipelines; using Aspire.Hosting.Publishing; using Aspire.Hosting.Testing; +using Aspire.Hosting.Tests; using Aspire.Hosting.Tests.Publishing; using Aspire.Hosting.Utils; using Aspire.TestUtilities; @@ -897,4 +899,76 @@ public async Task MultipleComputeEnvironmentsOnlyProcessTargetedResources() [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")] private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken); + + [Fact] + public async Task DestroyCompose_WithState_RunsComposeDown() + { + using var tempDir = new TestTempDirectory(); + + // Create a fake compose file so destroy finds it + var composeFilePath = Path.Combine(tempDir.Path, "docker-compose.yaml"); + await File.WriteAllTextAsync(composeFilePath, "version: '3'"); + + var fakeRuntime = new FakeContainerRuntime(); + var stateManager = new InMemoryDeploymentStateManager(); + stateManager.SetSection("DockerCompose:env", new System.Text.Json.Nodes.JsonObject + { + ["OutputPath"] = tempDir.Path, + ["ProjectName"] = "aspire-env-test", + ["ComposeFilePath"] = composeFilePath + }); + + var mockActivityReporter = new TestPipelineActivityReporter(output); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path, step: WellKnownPipelineSteps.Destroy); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(fakeRuntime); + builder.Services.AddSingleton(sp => (IContainerRuntimeResolver)sp.GetRequiredService()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(mockActivityReporter); + builder.Services.Configure(o => o.Yes = true); + + builder.AddDockerComposeEnvironment("env"); + builder.AddProject("api").PublishAsDockerFile(); + + using var app = builder.Build(); + await app.RunAsync(); + + // Verify compose down was called + Assert.True(fakeRuntime.WasComposeDownCalled); + + // Verify the destroy step ran + var createdSteps = mockActivityReporter.CreatedSteps; + Assert.Contains(createdSteps, s => s.Contains("destroy-compose-", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task DestroyCompose_WithNoState_ReportsNothingToDestroy() + { + using var tempDir = new TestTempDirectory(); + + var fakeRuntime = new FakeContainerRuntime(); + var stateManager = new InMemoryDeploymentStateManager(); + var mockActivityReporter = new TestPipelineActivityReporter(output); + + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path, step: WellKnownPipelineSteps.Destroy); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(fakeRuntime); + builder.Services.AddSingleton(sp => (IContainerRuntimeResolver)sp.GetRequiredService()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(mockActivityReporter); + builder.Services.Configure(o => o.Yes = true); + + builder.AddDockerComposeEnvironment("env"); + builder.AddProject("api").PublishAsDockerFile(); + + using var app = builder.Build(); + await app.RunAsync(); + + // Verify compose down was NOT called + Assert.False(fakeRuntime.WasComposeDownCalled); + + // Verify it reported nothing to destroy + var completedSteps = mockActivityReporter.CompletedSteps; + Assert.Contains(completedSteps, s => s.CompletionText.Contains("Nothing to destroy", StringComparison.OrdinalIgnoreCase)); + } } diff --git a/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs b/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs index 00b6354f090..8e95cd9e90b 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs @@ -21,6 +21,7 @@ public sealed class FakeContainerRuntime(bool shouldFail = false, bool isRunning public bool WasPushImageCalled { get; private set; } public bool WasBuildImageCalled { get; private set; } public bool WasLoginToRegistryCalled { get; private set; } + public bool WasComposeDownCalled { get; private set; } public ConcurrentBag<(string localImageName, string targetImageName)> TagImageCalls { get; } = []; public ConcurrentBag RemoveImageCalls { get; } = []; public ConcurrentBag PushImageCalls { get; } = []; @@ -115,6 +116,7 @@ public Task ComposeUpAsync(ComposeOperationContext context, CancellationToken ca public Task ComposeDownAsync(ComposeOperationContext context, CancellationToken cancellationToken) { + WasComposeDownCalled = true; if (shouldFail) { throw new DistributedApplicationException("Fake container runtime is configured to fail"); diff --git a/tests/Shared/InMemoryDeploymentStateManager.cs b/tests/Shared/InMemoryDeploymentStateManager.cs new file mode 100644 index 00000000000..72e23a0f2cd --- /dev/null +++ b/tests/Shared/InMemoryDeploymentStateManager.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. + +#pragma warning disable ASPIREPIPELINES002 + +using System.Text.Json.Nodes; +using Aspire.Hosting.Pipelines; + +namespace Aspire.Hosting.Tests; + +/// +/// In-memory deployment state manager for testing destroy scenarios. +/// +internal sealed class InMemoryDeploymentStateManager : IDeploymentStateManager +{ + private readonly Dictionary _sections = new(); + private readonly Dictionary _versions = new(); + + public string? StateFilePath => null; + + public void SetSection(string name, JsonObject data) + { + _sections[name] = data; + _versions[name] = 1; + } + + public Task AcquireSectionAsync(string sectionName, CancellationToken cancellationToken = default) + { + if (_sections.TryGetValue(sectionName, out var data)) + { + var version = _versions.GetValueOrDefault(sectionName, 0); + return Task.FromResult(new DeploymentStateSection(sectionName, data, version)); + } + return Task.FromResult(new DeploymentStateSection(sectionName, [], 0)); + } + + public Task SaveSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken = default) + { + _sections[section.SectionName] = section.Data; + _versions[section.SectionName] = section.Version + 1; + return Task.CompletedTask; + } + + public Task DeleteSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken = default) + { + _sections.Remove(section.SectionName); + _versions.Remove(section.SectionName); + return Task.CompletedTask; + } + + public Task ClearAllStateAsync(CancellationToken cancellationToken = default) + { + _sections.Clear(); + _versions.Clear(); + return Task.CompletedTask; + } +} From 762d6b7f91656d65c6e49c9168acbc5d2365ba63 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 14:48:53 -0700 Subject: [PATCH 19/35] Introduce IHelmRunner abstraction and add Helm destroy tests Extract IHelmRunner interface from HelmDeploymentEngine to enable testability of Helm operations without requiring a real helm binary. - IHelmRunner: abstraction for running helm CLI commands - DefaultHelmRunner: production implementation using ProcessUtil - FakeHelmRunner: test double that tracks calls and returns exit code 0 - Refactor HelmDeployAsync and HelmUninstallAsync to use IHelmRunner - Register DefaultHelmRunner in DI via AddKubernetesInfrastructureCore New tests: - DestroyHelm_WithState: verifies helm uninstall is called with saved release name and namespace from deployment state - DestroyHelm_WithNoState: verifies 'Nothing to destroy' without calling helm Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Deployment/DefaultHelmRunner.cs | 41 +++++ .../Deployment/HelmDeploymentEngine.cs | 166 ++++++------------ .../Deployment/IHelmRunner.cs | 26 +++ .../KubernetesEnvironmentExtensions.cs | 3 + .../Aspire.Hosting.Kubernetes.Tests.csproj | 1 + .../KubernetesDeployTests.cs | 94 ++++++++++ 6 files changed, 222 insertions(+), 109 deletions(-) create mode 100644 src/Aspire.Hosting.Kubernetes/Deployment/DefaultHelmRunner.cs create mode 100644 src/Aspire.Hosting.Kubernetes/Deployment/IHelmRunner.cs diff --git a/src/Aspire.Hosting.Kubernetes/Deployment/DefaultHelmRunner.cs b/src/Aspire.Hosting.Kubernetes/Deployment/DefaultHelmRunner.cs new file mode 100644 index 00000000000..8e84afd6f58 --- /dev/null +++ b/src/Aspire.Hosting.Kubernetes/Deployment/DefaultHelmRunner.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.Hosting.Dcp.Process; + +namespace Aspire.Hosting.Kubernetes; + +/// +/// Default implementation of that shells out to the helm CLI. +/// +internal sealed class DefaultHelmRunner : IHelmRunner +{ + public async Task RunAsync( + string arguments, + string? workingDirectory = null, + Action? onOutputData = null, + Action? onErrorData = null, + CancellationToken cancellationToken = default) + { + var spec = new ProcessSpec("helm") + { + Arguments = arguments, + WorkingDirectory = workingDirectory, + ThrowOnNonZeroReturnCode = false, + InheritEnv = true, + OnOutputData = onOutputData ?? (_ => { }), + OnErrorData = onErrorData ?? (_ => { }), + }; + + var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec); + + await using (processDisposable.ConfigureAwait(false)) + { + var processResult = await pendingProcessResult + .WaitAsync(cancellationToken) + .ConfigureAwait(false); + + return processResult.ExitCode; + } + } +} diff --git a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs index ec62711d241..a00de9ee0a5 100644 --- a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs +++ b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs @@ -401,8 +401,21 @@ private static async Task HelmDeployAsync(PipelineStepContext context, Kubernete { try { + var helmRunner = context.Services.GetRequiredService(); + // Verify helm is available - await VerifyToolAvailableAsync("helm", context.CancellationToken).ConfigureAwait(false); + try + { + var versionExitCode = await helmRunner.RunAsync("version --short", cancellationToken: context.CancellationToken).ConfigureAwait(false); + if (versionExitCode != 0) + { + throw new InvalidOperationException("'helm' is installed but returned an error. Ensure 'helm' is properly configured and your cluster is accessible."); + } + } + catch (Exception ex) when (ex is not InvalidOperationException and not OperationCanceledException) + { + throw new InvalidOperationException("'helm' was not found. Please install 'helm' and ensure it is available on your PATH to deploy to Kubernetes.", ex); + } var valuesFilePath = Path.Combine(outputPath, "values.yaml"); var arguments = new StringBuilder(); @@ -416,8 +429,6 @@ private static async Task HelmDeployAsync(PipelineStepContext context, Kubernete arguments.Append(CultureInfo.InvariantCulture, $" -f \"{valuesFilePath}\""); } - // Pass deploy-time override values (resolved secrets/parameters) after the - // base values.yaml so they take precedence via Helm's merge behavior. var deployValuesFilePath = Path.Combine(outputPath, GetDeployValuesFileName(environment.Name)); if (File.Exists(deployValuesFilePath)) { @@ -426,58 +437,41 @@ private static async Task HelmDeployAsync(PipelineStepContext context, Kubernete context.Logger.LogDebug("Running helm {Arguments}", arguments); - var stdoutBuilder = new StringBuilder(); var stderrBuilder = new StringBuilder(); - var spec = new ProcessSpec("helm") - { - Arguments = arguments.ToString(), - WorkingDirectory = outputPath, - ThrowOnNonZeroReturnCode = false, - InheritEnv = true, - OnOutputData = output => - { - stdoutBuilder.AppendLine(output); - context.Logger.LogDebug("helm (stdout): {Output}", output); - }, - OnErrorData = error => + var exitCode = await helmRunner.RunAsync( + arguments.ToString(), + workingDirectory: outputPath, + onOutputData: output => context.Logger.LogDebug("helm (stdout): {Output}", output), + onErrorData: error => { stderrBuilder.AppendLine(error); context.Logger.LogDebug("helm (stderr): {Error}", error); }, - }; + cancellationToken: context.CancellationToken).ConfigureAwait(false); - var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec); - - await using (processDisposable.ConfigureAwait(false)) + if (exitCode != 0) { - var processResult = await pendingProcessResult - .WaitAsync(context.CancellationToken) - .ConfigureAwait(false); - - if (processResult.ExitCode != 0) - { - var errorOutput = stderrBuilder.ToString().Trim(); - var message = string.IsNullOrEmpty(errorOutput) - ? $"helm upgrade --install failed with exit code {processResult.ExitCode}" - : $"helm upgrade --install failed: {errorOutput}"; + var errorOutput = stderrBuilder.ToString().Trim(); + var message = string.IsNullOrEmpty(errorOutput) + ? $"helm upgrade --install failed with exit code {exitCode}" + : $"helm upgrade --install failed: {errorOutput}"; - throw new InvalidOperationException(message); - } - else - { - // Persist deployment state so destroy can find the release - var deploymentStateManager = context.Services.GetRequiredService(); - var stateSection = await deploymentStateManager.AcquireSectionAsync($"Helm:{environment.Name}", context.CancellationToken).ConfigureAwait(false); - stateSection.Data["ReleaseName"] = releaseName; - stateSection.Data["Namespace"] = @namespace; - await deploymentStateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); - - await deployTask.CompleteAsync( - new MarkdownString($"Helm release **{releaseName}** deployed to namespace **{@namespace}**"), - CompletionState.Completed, - context.CancellationToken).ConfigureAwait(false); - } + throw new InvalidOperationException(message); + } + else + { + // Persist deployment state so destroy can find the release + var deploymentStateManager = context.Services.GetRequiredService(); + var stateSection = await deploymentStateManager.AcquireSectionAsync($"Helm:{environment.Name}", context.CancellationToken).ConfigureAwait(false); + stateSection.Data["ReleaseName"] = releaseName; + stateSection.Data["Namespace"] = @namespace; + await deploymentStateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); + + await deployTask.CompleteAsync( + new MarkdownString($"Helm release **{releaseName}** deployed to namespace **{@namespace}**"), + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); } } catch (Exception ex) when (ex is not OperationCanceledException) @@ -574,40 +568,29 @@ private static async Task HelmUninstallAsync(PipelineStepContext context, string { try { + var helmRunner = context.Services.GetRequiredService(); var arguments = $"uninstall {releaseName} --namespace {@namespace}"; context.Logger.LogDebug("Running helm {Arguments}", arguments); - var spec = new ProcessSpec("helm") - { - Arguments = arguments, - ThrowOnNonZeroReturnCode = false, - InheritEnv = true, - OnOutputData = output => context.Logger.LogDebug("helm (stdout): {Output}", output), - OnErrorData = error => context.Logger.LogDebug("helm (stderr): {Error}", error), - }; - - var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec); + var exitCode = await helmRunner.RunAsync( + arguments, + onOutputData: output => context.Logger.LogDebug("helm (stdout): {Output}", output), + onErrorData: error => context.Logger.LogDebug("helm (stderr): {Error}", error), + cancellationToken: context.CancellationToken).ConfigureAwait(false); - await using (processDisposable.ConfigureAwait(false)) + if (exitCode != 0) { - var processResult = await pendingProcessResult - .WaitAsync(context.CancellationToken) - .ConfigureAwait(false); - - if (processResult.ExitCode != 0) - { - await uninstallTask.FailAsync( - $"helm uninstall failed with exit code {processResult.ExitCode}", - cancellationToken: context.CancellationToken).ConfigureAwait(false); - } - else - { - await uninstallTask.CompleteAsync( - new MarkdownString($"Helm release **{releaseName}** uninstalled from namespace **{@namespace}**"), - CompletionState.Completed, - context.CancellationToken).ConfigureAwait(false); - } + await uninstallTask.FailAsync( + $"helm uninstall failed with exit code {exitCode}", + cancellationToken: context.CancellationToken).ConfigureAwait(false); + } + else + { + await uninstallTask.CompleteAsync( + new MarkdownString($"Helm release **{releaseName}** uninstalled from namespace **{@namespace}**"), + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); } } catch (Exception ex) when (ex is not OperationCanceledException) @@ -741,39 +724,4 @@ private static async Task> GetServiceEndpointsAsync( return endpoints; } - - private static async Task VerifyToolAvailableAsync(string tool, CancellationToken cancellationToken) - { - var spec = new ProcessSpec(tool) - { - Arguments = "version --short", - ThrowOnNonZeroReturnCode = false, - InheritEnv = true, - OnOutputData = _ => { }, - OnErrorData = _ => { }, - }; - - try - { - var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec); - - await using (processDisposable.ConfigureAwait(false)) - { - var result = await pendingProcessResult - .WaitAsync(cancellationToken) - .ConfigureAwait(false); - - if (result.ExitCode != 0) - { - throw new InvalidOperationException( - $"'{tool}' is installed but returned an error. Ensure '{tool}' is properly configured and your cluster is accessible."); - } - } - } - catch (Exception ex) when (ex is not InvalidOperationException and not OperationCanceledException) - { - throw new InvalidOperationException( - $"'{tool}' was not found. Please install '{tool}' and ensure it is available on your PATH to deploy to Kubernetes.", ex); - } - } } diff --git a/src/Aspire.Hosting.Kubernetes/Deployment/IHelmRunner.cs b/src/Aspire.Hosting.Kubernetes/Deployment/IHelmRunner.cs new file mode 100644 index 00000000000..3b41caa52c4 --- /dev/null +++ b/src/Aspire.Hosting.Kubernetes/Deployment/IHelmRunner.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.Hosting.Kubernetes; + +/// +/// Abstraction for running Helm CLI commands, enabling testability of Helm operations. +/// +internal interface IHelmRunner +{ + /// + /// Runs a Helm command with the specified arguments. + /// + /// The arguments to pass to the helm command. + /// The working directory for the process, or null to use the current directory. + /// Callback for stdout lines. + /// Callback for stderr lines. + /// The cancellation token. + /// The process exit code. + Task RunAsync( + string arguments, + string? workingDirectory = null, + Action? onOutputData = null, + Action? onErrorData = null, + CancellationToken cancellationToken = default); +} diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentExtensions.cs b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentExtensions.cs index 0483de78945..4888211753e 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentExtensions.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentExtensions.cs @@ -5,6 +5,8 @@ using Aspire.Hosting.Kubernetes; using Aspire.Hosting.Kubernetes.Extensions; using Aspire.Hosting.Lifecycle; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Aspire.Hosting; @@ -16,6 +18,7 @@ public static class KubernetesEnvironmentExtensions internal static IDistributedApplicationBuilder AddKubernetesInfrastructureCore(this IDistributedApplicationBuilder builder) { builder.Services.TryAddEventingSubscriber(); + builder.Services.TryAddSingleton(); return builder; } diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Aspire.Hosting.Kubernetes.Tests.csproj b/tests/Aspire.Hosting.Kubernetes.Tests/Aspire.Hosting.Kubernetes.Tests.csproj index 0f2efe80659..c782824608c 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Aspire.Hosting.Kubernetes.Tests.csproj +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Aspire.Hosting.Kubernetes.Tests.csproj @@ -20,6 +20,7 @@ + diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs index 07e42cb4ec6..f4c3c33a7c2 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs @@ -4,9 +4,11 @@ #pragma warning disable ASPIRECOMPUTE002 #pragma warning disable ASPIRECOMPUTE003 #pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES002 #pragma warning disable ASPIREPIPELINES003 #pragma warning disable ASPIRECONTAINERRUNTIME001 +using System.Text.Json.Nodes; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Pipelines; using Aspire.Hosting.Publishing; @@ -1312,4 +1314,96 @@ public void Dashboard_ResourceHasCorrectEndpoints() Assert.Equal("http", dashboard.PrimaryEndpoint.EndpointName); Assert.Equal("otlp-grpc", dashboard.OtlpGrpcEndpoint.EndpointName); } + + [Fact] + public async Task DestroyHelm_WithState_RunsHelmUninstall() + { + using var tempDir = new TestTempDirectory(); + + var fakeHelm = new FakeHelmRunner(); + var stateManager = new InMemoryDeploymentStateManager(); + stateManager.SetSection("Helm:env", new JsonObject + { + ["ReleaseName"] = "my-release", + ["Namespace"] = "my-namespace" + }); + + var mockActivityReporter = new TestPipelineActivityReporter(output); + var builder = TestDistributedApplicationBuilder.Create( + DistributedApplicationOperation.Publish, + tempDir.Path, + step: WellKnownPipelineSteps.Destroy); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(mockActivityReporter); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(fakeHelm); + builder.Services.Configure(o => o.Yes = true); + + builder.AddKubernetesEnvironment("env"); + builder.AddContainer("api", "myimage"); + + using var app = builder.Build(); + await app.RunAsync(); + + // Verify helm uninstall was called with saved state values + Assert.True(fakeHelm.WasUninstallCalled); + Assert.Contains("my-release", fakeHelm.LastArguments!); + Assert.Contains("my-namespace", fakeHelm.LastArguments!); + } + + [Fact] + public async Task DestroyHelm_WithNoState_ReportsNothingToDestroy() + { + using var tempDir = new TestTempDirectory(); + + var fakeHelm = new FakeHelmRunner(); + var stateManager = new InMemoryDeploymentStateManager(); + var mockActivityReporter = new TestPipelineActivityReporter(output); + + var builder = TestDistributedApplicationBuilder.Create( + DistributedApplicationOperation.Publish, + tempDir.Path, + step: WellKnownPipelineSteps.Destroy); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(mockActivityReporter); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(fakeHelm); + builder.Services.Configure(o => o.Yes = true); + + builder.AddKubernetesEnvironment("env"); + builder.AddContainer("api", "myimage"); + + using var app = builder.Build(); + await app.RunAsync(); + + // Verify helm was NOT called + Assert.False(fakeHelm.WasUninstallCalled); + + // Verify it reported nothing to destroy + var completedSteps = mockActivityReporter.CompletedSteps; + Assert.Contains(completedSteps, s => s.CompletionText.Contains("Nothing to destroy", StringComparison.OrdinalIgnoreCase)); + } + + private sealed class FakeHelmRunner : IHelmRunner + { + public bool WasUninstallCalled { get; private set; } + public string? LastArguments { get; private set; } + + public Task RunAsync( + string arguments, + string? workingDirectory = null, + Action? onOutputData = null, + Action? onErrorData = null, + CancellationToken cancellationToken = default) + { + LastArguments = arguments; + if (arguments.StartsWith("uninstall", StringComparison.OrdinalIgnoreCase)) + { + WasUninstallCalled = true; + } + return Task.FromResult(0); + } + } } From b0d4cc458e14df3589532d915c74d1b7e6ae08a4 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 15:02:54 -0700 Subject: [PATCH 20/35] =?UTF-8?q?Improve=20test=20quality:=20observable=20?= =?UTF-8?q?ARM=20mocks=20and=20deploy=E2=86=92destroy=20roundtrip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make TestResourceGroupResource observable with WasDeleteCalled and WasGetResourcesCalled tracking, threaded through ARM client/subscription - Azure destroy test now asserts ARM DeleteAsync and GetResourcesAsync were actually called, not just that the step was created - Add deploy→destroy roundtrip test for Docker Compose that verifies state persisted during deploy is correctly consumed by destroy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AzureDeployerTests.cs | 11 ++-- .../ProvisioningTestHelpers.cs | 35 +++++++++--- .../DockerComposeTests.cs | 54 +++++++++++++++++++ 3 files changed, 90 insertions(+), 10 deletions(-) diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs index 33790043823..29b1f086642 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs @@ -1750,9 +1750,12 @@ public async Task DestroyAsync_WithAzureState_DeletesResourceGroup() ["Location"] = "westus2" }); + var testResourceGroup = new TestResourceGroupResource("rg-test-destroy"); + var armClientProvider = new TestArmClientProvider(testResourceGroup); + var mockActivityReporter = new TestPipelineActivityReporter(testOutputHelper); var testInteractionService = new TestInteractionService(); - ConfigureTestServices(builder, interactionService: testInteractionService, bicepProvisioner: new NoOpBicepProvisioner(), activityReporter: mockActivityReporter, deploymentStateManager: stateManager, setDefaultProvisioningOptions: false); + ConfigureTestServices(builder, interactionService: testInteractionService, bicepProvisioner: new NoOpBicepProvisioner(), armClientProvider: armClientProvider, activityReporter: mockActivityReporter, deploymentStateManager: stateManager, setDefaultProvisioningOptions: false); builder.Services.Configure(o => o.Yes = true); builder.AddAzureContainerAppEnvironment("aca"); @@ -1761,9 +1764,9 @@ public async Task DestroyAsync_WithAzureState_DeletesResourceGroup() using var app = builder.Build(); await app.RunAsync(); - // Verify the destroy step ran successfully (check tasks, not step completion) - var createdSteps = mockActivityReporter.CreatedSteps; - Assert.Contains(createdSteps, s => s.Contains("destroy-azure-", StringComparison.OrdinalIgnoreCase)); + // Verify the resource group was actually deleted via ARM + Assert.True(testResourceGroup.WasDeleteCalled, "DeleteAsync should have been called on the resource group"); + Assert.True(testResourceGroup.WasGetResourcesCalled, "GetResourcesAsync should have been called to enumerate resources before deletion"); } [Fact] diff --git a/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs b/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs index 0b531aa2fd1..520e9a1b359 100644 --- a/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs +++ b/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs @@ -181,10 +181,12 @@ internal sealed class TestArmClient : IArmClient { private readonly Dictionary? _deploymentOutputs; private readonly Func>? _deploymentOutputsProvider; + private readonly TestResourceGroupResource? _resourceGroup; - public TestArmClient(Dictionary deploymentOutputs) + public TestArmClient(Dictionary deploymentOutputs, TestResourceGroupResource? resourceGroup = null) { _deploymentOutputs = deploymentOutputs; + _resourceGroup = resourceGroup; } public TestArmClient(Func> deploymentOutputsProvider) @@ -205,7 +207,7 @@ public TestArmClient() : this([]) } else { - subscription = new TestSubscriptionResource(_deploymentOutputs!); + subscription = new TestSubscriptionResource(_deploymentOutputs!, _resourceGroup); } var tenant = new TestTenantResource(); return Task.FromResult<(ISubscriptionResource, ITenantResource)>((subscription, tenant)); @@ -271,10 +273,12 @@ internal sealed class TestSubscriptionResource : ISubscriptionResource { private readonly Dictionary? _deploymentOutputs; private readonly Func>? _deploymentOutputsProvider; + private readonly TestResourceGroupResource? _resourceGroup; - public TestSubscriptionResource(Dictionary deploymentOutputs) + public TestSubscriptionResource(Dictionary deploymentOutputs, TestResourceGroupResource? resourceGroup = null) { _deploymentOutputs = deploymentOutputs; + _resourceGroup = resourceGroup; } public TestSubscriptionResource(Func> deploymentOutputsProvider) @@ -305,7 +309,7 @@ public IResourceGroupCollection GetResourceGroups() { return new TestResourceGroupCollection(_deploymentOutputsProvider); } - return new TestResourceGroupCollection(_deploymentOutputs!); + return new TestResourceGroupCollection(_deploymentOutputs!, _resourceGroup); } } @@ -316,10 +320,12 @@ internal sealed class TestResourceGroupCollection : IResourceGroupCollection { private readonly Dictionary? _deploymentOutputs; private readonly Func>? _deploymentOutputsProvider; + private readonly TestResourceGroupResource? _resourceGroup; - public TestResourceGroupCollection(Dictionary deploymentOutputs) + public TestResourceGroupCollection(Dictionary deploymentOutputs, TestResourceGroupResource? resourceGroup = null) { _deploymentOutputs = deploymentOutputs; + _resourceGroup = resourceGroup; } public TestResourceGroupCollection(Func> deploymentOutputsProvider) @@ -333,6 +339,11 @@ public TestResourceGroupCollection() : this([]) public Task> GetAsync(string resourceGroupName, CancellationToken cancellationToken = default) { + if (_resourceGroup is not null) + { + return Task.FromResult(Response.FromValue(_resourceGroup, new MockResponse(200))); + } + IResourceGroupResource resourceGroup; if (_deploymentOutputsProvider is not null) { @@ -398,13 +409,18 @@ public IArmDeploymentCollection GetArmDeployments() return new TestArmDeploymentCollection(_deploymentOutputs!); } + public bool WasDeleteCalled { get; private set; } + public bool WasGetResourcesCalled { get; private set; } + public Task DeleteAsync(WaitUntil waitUntil, CancellationToken cancellationToken = default) { + WasDeleteCalled = true; return Task.CompletedTask; } public async IAsyncEnumerable<(string Name, string ResourceType)> GetResourcesAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { + WasGetResourcesCalled = true; await Task.CompletedTask; yield break; } @@ -553,6 +569,7 @@ internal sealed class TestArmClientProvider : IArmClientProvider { private readonly Dictionary? _deploymentOutputs; private readonly Func>? _deploymentOutputsProvider; + private readonly TestResourceGroupResource? _resourceGroup; public TestArmClientProvider(Dictionary deploymentOutputs) { @@ -564,6 +581,12 @@ public TestArmClientProvider(Func> deployment _deploymentOutputsProvider = deploymentOutputsProvider; } + public TestArmClientProvider(TestResourceGroupResource resourceGroup) + { + _resourceGroup = resourceGroup; + _deploymentOutputs = []; + } + public TestArmClientProvider() : this([]) { } @@ -574,7 +597,7 @@ public IArmClient GetArmClient(TokenCredential credential, string subscriptionId { return new TestArmClient(_deploymentOutputsProvider); } - return new TestArmClient(_deploymentOutputs!); + return new TestArmClient(_deploymentOutputs!, _resourceGroup); } public IArmClient GetArmClient(TokenCredential credential) diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs index 240fd6a28e7..b80fe0d4b28 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs @@ -971,4 +971,58 @@ public async Task DestroyCompose_WithNoState_ReportsNothingToDestroy() var completedSteps = mockActivityReporter.CompletedSteps; Assert.Contains(completedSteps, s => s.CompletionText.Contains("Nothing to destroy", StringComparison.OrdinalIgnoreCase)); } + + [Fact] + public async Task DeployThenDestroy_RoundTrip_UsesPersistedState() + { + using var tempDir = new TestTempDirectory(); + + var fakeRuntime = new FakeContainerRuntime(); + var stateManager = new InMemoryDeploymentStateManager(); + var mockActivityReporter = new TestPipelineActivityReporter(output); + + // Step 1: Deploy — this should persist state + var deployBuilder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path, step: WellKnownPipelineSteps.Deploy); + deployBuilder.Services.AddSingleton(); + deployBuilder.Services.AddSingleton(fakeRuntime); + deployBuilder.Services.AddSingleton(sp => (IContainerRuntimeResolver)sp.GetRequiredService()); + deployBuilder.Services.AddSingleton(stateManager); + deployBuilder.Services.AddSingleton(mockActivityReporter); + + deployBuilder.AddDockerComposeEnvironment("env"); + deployBuilder.AddProject("api").PublishAsDockerFile(); + + using (var deployApp = deployBuilder.Build()) + { + await deployApp.RunAsync(); + } + + // Verify deploy persisted state + var stateSection = await stateManager.AcquireSectionAsync("DockerCompose:env"); + Assert.NotNull(stateSection.Data["ComposeFilePath"]?.ToString()); + Assert.NotNull(stateSection.Data["ProjectName"]?.ToString()); + + // Step 2: Destroy — should read the persisted state and call compose down + fakeRuntime = new FakeContainerRuntime(); // fresh runtime to track destroy calls + mockActivityReporter.Clear(); + + var destroyBuilder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path, step: WellKnownPipelineSteps.Destroy); + destroyBuilder.Services.AddSingleton(); + destroyBuilder.Services.AddSingleton(fakeRuntime); + destroyBuilder.Services.AddSingleton(sp => (IContainerRuntimeResolver)sp.GetRequiredService()); + destroyBuilder.Services.AddSingleton(stateManager); + destroyBuilder.Services.AddSingleton(mockActivityReporter); + destroyBuilder.Services.Configure(o => o.Yes = true); + + destroyBuilder.AddDockerComposeEnvironment("env"); + destroyBuilder.AddProject("api").PublishAsDockerFile(); + + using (var destroyApp = destroyBuilder.Build()) + { + await destroyApp.RunAsync(); + } + + // Verify compose down was called using the state from deploy + Assert.True(fakeRuntime.WasComposeDownCalled, "ComposeDownAsync should have been called using persisted state from deploy"); + } } From 3f531d5481ee32e2efa856547178ad014ec651ec Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 15:19:57 -0700 Subject: [PATCH 21/35] Rename PipelineOptions.Yes to SkipConfirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clearer intent — 'Yes' was ambiguous, 'SkipConfirmation' describes exactly what the option does. The CLI flag remains --yes/-y. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs | 4 ++-- .../DockerComposeEnvironmentResource.cs | 2 +- .../Deployment/HelmDeploymentEngine.cs | 2 +- src/Aspire.Hosting/DistributedApplicationBuilder.cs | 2 +- src/Aspire.Hosting/Pipelines/PipelineOptions.cs | 2 +- tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs | 4 ++-- tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs | 6 +++--- .../KubernetesDeployTests.cs | 4 ++-- 8 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 9eca7fb5495..265f7c9d4e7 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -221,7 +221,7 @@ await context.ReportingStep.CompleteAsync( // Fail fast in non-interactive mode without --yes before doing any Azure work var options = context.Services.GetRequiredService>(); - if (!options.Value.Yes) + if (!options.Value.SkipConfirmation) { var interactionService = context.Services.GetRequiredService(); if (!interactionService.IsAvailable) @@ -302,7 +302,7 @@ await discoveryTask.CompleteAsync( } // Confirm destruction with the user (unless --yes was specified) - if (!options.Value.Yes) + if (!options.Value.SkipConfirmation) { var interactionService = context.Services.GetRequiredService(); diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs index 759c6c016e7..24218e80b3f 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs @@ -367,7 +367,7 @@ private static async Task ConfirmDestroyAsync(PipelineStepContext context, strin { var options = context.Services.GetRequiredService>(); - if (!options.Value.Yes) + if (!options.Value.SkipConfirmation) { var interactionService = context.Services.GetRequiredService(); diff --git a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs index a00de9ee0a5..bb99c0a5d76 100644 --- a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs +++ b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs @@ -608,7 +608,7 @@ private static async Task ConfirmDestroyAsync(PipelineStepContext context, strin { var options = context.Services.GetRequiredService>(); - if (!options.Value.Yes) + if (!options.Value.SkipConfirmation) { var interactionService = context.Services.GetRequiredService(); diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 720c040d2b6..5cb734510d2 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -647,7 +647,7 @@ private void ConfigurePipelineOptions(DistributedApplicationOptions options) // TODO: Rename this to something related to deployment state { "--clear-cache", "Pipeline:ClearCache" }, - { "--yes", "Pipeline:Yes" }, + { "--yes", "Pipeline:SkipConfirmation" }, // DCP Publisher options, we should only process these in run mode { "--dcp-cli-path", "DcpPublisher:CliPath" }, diff --git a/src/Aspire.Hosting/Pipelines/PipelineOptions.cs b/src/Aspire.Hosting/Pipelines/PipelineOptions.cs index bacb640d808..a804811d83b 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineOptions.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineOptions.cs @@ -36,5 +36,5 @@ public class PipelineOptions /// /// Gets or sets a value indicating whether to skip confirmation prompts for destructive operations. /// - public bool Yes { get; set; } + public bool SkipConfirmation { get; set; } } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs index 29b1f086642..9226e6f0ade 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs @@ -1756,7 +1756,7 @@ public async Task DestroyAsync_WithAzureState_DeletesResourceGroup() var mockActivityReporter = new TestPipelineActivityReporter(testOutputHelper); var testInteractionService = new TestInteractionService(); ConfigureTestServices(builder, interactionService: testInteractionService, bicepProvisioner: new NoOpBicepProvisioner(), armClientProvider: armClientProvider, activityReporter: mockActivityReporter, deploymentStateManager: stateManager, setDefaultProvisioningOptions: false); - builder.Services.Configure(o => o.Yes = true); + builder.Services.Configure(o => o.SkipConfirmation = true); builder.AddAzureContainerAppEnvironment("aca"); builder.AddContainer("api", "myimage"); @@ -1777,7 +1777,7 @@ public async Task DestroyAsync_WithNoAzureState_ReportsNothingToDestroy() var mockActivityReporter = new TestPipelineActivityReporter(testOutputHelper); ConfigureTestServices(builder, bicepProvisioner: new NoOpBicepProvisioner(), activityReporter: mockActivityReporter, deploymentStateManager: stateManager, setDefaultProvisioningOptions: false); - builder.Services.Configure(o => o.Yes = true); + builder.Services.Configure(o => o.SkipConfirmation = true); builder.AddAzureContainerAppEnvironment("aca"); builder.AddContainer("api", "myimage"); diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs index b80fe0d4b28..188126adf05 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs @@ -925,7 +925,7 @@ public async Task DestroyCompose_WithState_RunsComposeDown() builder.Services.AddSingleton(sp => (IContainerRuntimeResolver)sp.GetRequiredService()); builder.Services.AddSingleton(stateManager); builder.Services.AddSingleton(mockActivityReporter); - builder.Services.Configure(o => o.Yes = true); + builder.Services.Configure(o => o.SkipConfirmation = true); builder.AddDockerComposeEnvironment("env"); builder.AddProject("api").PublishAsDockerFile(); @@ -956,7 +956,7 @@ public async Task DestroyCompose_WithNoState_ReportsNothingToDestroy() builder.Services.AddSingleton(sp => (IContainerRuntimeResolver)sp.GetRequiredService()); builder.Services.AddSingleton(stateManager); builder.Services.AddSingleton(mockActivityReporter); - builder.Services.Configure(o => o.Yes = true); + builder.Services.Configure(o => o.SkipConfirmation = true); builder.AddDockerComposeEnvironment("env"); builder.AddProject("api").PublishAsDockerFile(); @@ -1012,7 +1012,7 @@ public async Task DeployThenDestroy_RoundTrip_UsesPersistedState() destroyBuilder.Services.AddSingleton(sp => (IContainerRuntimeResolver)sp.GetRequiredService()); destroyBuilder.Services.AddSingleton(stateManager); destroyBuilder.Services.AddSingleton(mockActivityReporter); - destroyBuilder.Services.Configure(o => o.Yes = true); + destroyBuilder.Services.Configure(o => o.SkipConfirmation = true); destroyBuilder.AddDockerComposeEnvironment("env"); destroyBuilder.AddProject("api").PublishAsDockerFile(); diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs index f4c3c33a7c2..e565c7f0415 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs @@ -1338,7 +1338,7 @@ public async Task DestroyHelm_WithState_RunsHelmUninstall() builder.Services.AddSingleton(mockActivityReporter); builder.Services.AddSingleton(stateManager); builder.Services.AddSingleton(fakeHelm); - builder.Services.Configure(o => o.Yes = true); + builder.Services.Configure(o => o.SkipConfirmation = true); builder.AddKubernetesEnvironment("env"); builder.AddContainer("api", "myimage"); @@ -1370,7 +1370,7 @@ public async Task DestroyHelm_WithNoState_ReportsNothingToDestroy() builder.Services.AddSingleton(mockActivityReporter); builder.Services.AddSingleton(stateManager); builder.Services.AddSingleton(fakeHelm); - builder.Services.Configure(o => o.Yes = true); + builder.Services.Configure(o => o.SkipConfirmation = true); builder.AddKubernetesEnvironment("env"); builder.AddContainer("api", "myimage"); From 55aa8c91c07a15b5e3b5ba7d989aa2977ae1dfb1 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 15:28:15 -0700 Subject: [PATCH 22/35] Add API compat suppressions for new interface members Suppress CP0006 for ClearAllStateAsync added to IDeploymentStateManager and Compose methods added to IContainerRuntime. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting/CompatibilitySuppressions.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Aspire.Hosting/CompatibilitySuppressions.xml b/src/Aspire.Hosting/CompatibilitySuppressions.xml index 5820a6da429..1200e513ce8 100644 --- a/src/Aspire.Hosting/CompatibilitySuppressions.xml +++ b/src/Aspire.Hosting/CompatibilitySuppressions.xml @@ -1,6 +1,13 @@  + + CP0006 + M:Aspire.Hosting.Pipelines.IDeploymentStateManager.ClearAllStateAsync(System.Threading.CancellationToken) + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + CP0006 M:Aspire.Hosting.Publishing.IContainerRuntime.ComposeDownAsync(Aspire.Hosting.Publishing.ComposeOperationContext,System.Threading.CancellationToken) From 3c5d18e24e3daee04e70601bf321d10d4b33b7fe Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 15:52:34 -0700 Subject: [PATCH 23/35] Fix unused using in KubernetesEnvironmentExtensions Removes redundant Microsoft.Extensions.DependencyInjection using (only Extensions variant needed for TryAddSingleton). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentExtensions.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentExtensions.cs b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentExtensions.cs index 4888211753e..f12456f6473 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentExtensions.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentExtensions.cs @@ -5,7 +5,6 @@ using Aspire.Hosting.Kubernetes; using Aspire.Hosting.Kubernetes.Extensions; using Aspire.Hosting.Lifecycle; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; namespace Aspire.Hosting; From d70f56ff7feb9f02c37d052e82c09713c504385f Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 16:11:40 -0700 Subject: [PATCH 24/35] Add Azure portal link to destroy summary Link to the resource group in the portal so users can monitor the async deletion operation or diagnose failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 265f7c9d4e7..17add3ad727 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -340,7 +340,9 @@ await discoveryTask.CompleteAsync( { await resourceGroup.DeleteAsync(WaitUntil.Started, context.CancellationToken).ConfigureAwait(false); - context.Summary.Add("🗑️ Resource Group", resourceGroupName); + var tenantSegment = subscription.TenantId.HasValue ? $"#@{subscription.TenantId.Value}" : "#"; + var portalUrl = $"https://portal.azure.com/{tenantSegment}/resource/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/overview"; + context.Summary.Add("🗑️ Resource Group", new MarkdownString($"[{resourceGroupName}]({portalUrl})")); context.Summary.Add("🔑 Subscription", subscriptionId); await deleteTask.CompleteAsync( From 728e244144fc7733ea75e3172f69d48eeda1991d Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 16:15:43 -0700 Subject: [PATCH 25/35] Extract AzurePortalUrls helper for shared portal URL generation Consolidate portal URL construction into a single shared class: - GetResourceGroupUrl: used by deploy summary and destroy summary - GetDeploymentUrl(string, string, string): used by BicepProvisioner - GetDeploymentUrl(ResourceIdentifier): used by BicepProvisioner Removes duplicate URL construction logic from AzureEnvironmentResource and BicepProvisioner. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AzureEnvironmentResource.cs | 6 +-- src/Aspire.Hosting.Azure/AzurePortalUrls.cs | 39 +++++++++++++++++++ .../Provisioners/BicepProvisioner.cs | 15 +------ 3 files changed, 43 insertions(+), 17 deletions(-) create mode 100644 src/Aspire.Hosting.Azure/AzurePortalUrls.cs diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 17add3ad727..71931e077d8 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -149,8 +149,7 @@ private static void AddToPipelineSummary(PipelineStepContext ctx, ProvisioningCo var location = provisioningContext.Location.Name; 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 portalUrl = AzurePortalUrls.GetResourceGroupUrl(subscriptionId, resourceGroupName, tenantId); var resourceGroupValue = $"[{resourceGroupName}]({portalUrl})"; ctx.Summary.Add("☁️ Target", "Azure"); @@ -340,8 +339,7 @@ await discoveryTask.CompleteAsync( { await resourceGroup.DeleteAsync(WaitUntil.Started, context.CancellationToken).ConfigureAwait(false); - var tenantSegment = subscription.TenantId.HasValue ? $"#@{subscription.TenantId.Value}" : "#"; - var portalUrl = $"https://portal.azure.com/{tenantSegment}/resource/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/overview"; + var portalUrl = AzurePortalUrls.GetResourceGroupUrl(subscriptionId, resourceGroupName, subscription.TenantId); context.Summary.Add("🗑️ Resource Group", new MarkdownString($"[{resourceGroupName}]({portalUrl})")); context.Summary.Add("🔑 Subscription", subscriptionId); diff --git a/src/Aspire.Hosting.Azure/AzurePortalUrls.cs b/src/Aspire.Hosting.Azure/AzurePortalUrls.cs new file mode 100644 index 00000000000..28c95acb5b0 --- /dev/null +++ b/src/Aspire.Hosting.Azure/AzurePortalUrls.cs @@ -0,0 +1,39 @@ +// 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; + +/// +/// Helpers for generating Azure portal URLs. +/// +internal static class AzurePortalUrls +{ + private const string PortalDeploymentOverviewUrl = "https://portal.azure.com/#view/HubsExtension/DeploymentDetailsBlade/~/overview/id"; + + /// + /// Gets the Azure portal URL for a resource group overview page. + /// + internal static string GetResourceGroupUrl(string subscriptionId, string resourceGroupName, Guid? tenantId = null) + { + var tenantSegment = tenantId.HasValue ? $"#@{tenantId.Value}" : "#"; + return $"https://portal.azure.com/{tenantSegment}/resource/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/overview"; + } + + /// + /// Gets the Azure portal URL for a deployment details page. + /// + internal static string GetDeploymentUrl(string subscriptionResourceId, string resourceGroupName, string deploymentName) + { + var path = $"{subscriptionResourceId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Resources/deployments/{deploymentName}"; + var encodedPath = Uri.EscapeDataString(path); + return $"{PortalDeploymentOverviewUrl}/{encodedPath}"; + } + + /// + /// Gets the Azure portal URL for a deployment details page using a full deployment resource ID. + /// + internal static string GetDeploymentUrl(global::Azure.Core.ResourceIdentifier deploymentId) + { + return $"{PortalDeploymentOverviewUrl}/{Uri.EscapeDataString(deploymentId.ToString())}"; + } +} diff --git a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs index e0bf100de2f..4e70f3c4d66 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs @@ -355,24 +355,13 @@ static void ValidateUnknownPrincipalParameter(ProvisioningContext context) resource.Parameters[AzureBicepResource.KnownParameters.Location] = context.Location.Name; } - private const string PortalDeploymentOverviewUrl = "https://portal.azure.com/#view/HubsExtension/DeploymentDetailsBlade/~/overview/id"; - private static string GetDeploymentUrl(ProvisioningContext provisioningContext, IResourceGroupResource resourceGroup, string deploymentName) { - var prefix = PortalDeploymentOverviewUrl; - var subId = provisioningContext.Subscription.Id.ToString(); var rgName = resourceGroup.Name; - var subAndRg = $"{subId}/resourceGroups/{rgName}"; - - var deployId = deploymentName; - - var path = $"{subAndRg}/providers/Microsoft.Resources/deployments/{deployId}"; - var encodedPath = Uri.EscapeDataString(path); - - return $"{prefix}/{encodedPath}"; + return AzurePortalUrls.GetDeploymentUrl(subId, rgName, deploymentName); } public static string GetDeploymentUrl(ResourceIdentifier deploymentId) => - $"{PortalDeploymentOverviewUrl}/{Uri.EscapeDataString(deploymentId.ToString())}"; + AzurePortalUrls.GetDeploymentUrl(deploymentId); } From c0c3b04cdbb3254f7c86f822a47005897492348d Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 20:31:35 -0700 Subject: [PATCH 26/35] Address PR review feedback - Improve --yes option description to be clearer (JamesNK) - Change confirmation button text from 'Yes, destroy' to 'Destroy' (JamesNK) - Fix Helm: throw on non-zero exit so state isn't cleared on failure (JamesNK) - Fix ClearAllStateAsync: acquire _stateLock before mutating in-memory state to maintain locking discipline (JamesNK) - Fix missing ReportingStep.CompleteAsync when compose file no longer exists during destroy (JamesNK) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DestroyCommandStrings.Designer.cs | 2 +- .../Resources/DestroyCommandStrings.resx | 2 +- .../xlf/DestroyCommandStrings.cs.xlf | 4 ++-- .../xlf/DestroyCommandStrings.de.xlf | 4 ++-- .../xlf/DestroyCommandStrings.es.xlf | 4 ++-- .../xlf/DestroyCommandStrings.fr.xlf | 4 ++-- .../xlf/DestroyCommandStrings.it.xlf | 4 ++-- .../xlf/DestroyCommandStrings.ja.xlf | 4 ++-- .../xlf/DestroyCommandStrings.ko.xlf | 4 ++-- .../xlf/DestroyCommandStrings.pl.xlf | 4 ++-- .../xlf/DestroyCommandStrings.pt-BR.xlf | 4 ++-- .../xlf/DestroyCommandStrings.ru.xlf | 4 ++-- .../xlf/DestroyCommandStrings.tr.xlf | 4 ++-- .../xlf/DestroyCommandStrings.zh-Hans.xlf | 4 ++-- .../xlf/DestroyCommandStrings.zh-Hant.xlf | 4 ++-- .../AzureEnvironmentResource.cs | 2 +- .../DockerComposeEnvironmentResource.cs | 6 +++++- .../Deployment/HelmDeploymentEngine.cs | 3 ++- .../Internal/DeploymentStateManagerBase.cs | 21 ++++++++++++------- 19 files changed, 49 insertions(+), 39 deletions(-) diff --git a/src/Aspire.Cli/Resources/DestroyCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/DestroyCommandStrings.Designer.cs index 224986475b6..b4c227295be 100644 --- a/src/Aspire.Cli/Resources/DestroyCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/DestroyCommandStrings.Designer.cs @@ -106,7 +106,7 @@ public static string OperationFailedPrefix { } /// - /// Looks up a localized string similar to Skip the confirmation prompt and proceed with the destroy operation. + /// Looks up a localized string similar to Do not prompt for confirmation before destroying resources. /// public static string YesOptionDescription { get { diff --git a/src/Aspire.Cli/Resources/DestroyCommandStrings.resx b/src/Aspire.Cli/Resources/DestroyCommandStrings.resx index 5be88ea6f8f..2bda5165252 100644 --- a/src/Aspire.Cli/Resources/DestroyCommandStrings.resx +++ b/src/Aspire.Cli/Resources/DestroyCommandStrings.resx @@ -133,6 +133,6 @@ DESTROY FAILED - Skip the confirmation prompt and proceed with the destroy operation + Do not prompt for confirmation before destroying resources diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.cs.xlf index 2a491503ece..a9f400ed2e7 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.cs.xlf @@ -28,8 +28,8 @@ - Skip the confirmation prompt and proceed with the destroy operation - Skip the confirmation prompt and proceed with the destroy operation + Do not prompt for confirmation before destroying resources + Do not prompt for confirmation before destroying resources diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.de.xlf index 6001569f0f3..02798caedfa 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.de.xlf @@ -28,8 +28,8 @@ - Skip the confirmation prompt and proceed with the destroy operation - Skip the confirmation prompt and proceed with the destroy operation + Do not prompt for confirmation before destroying resources + Do not prompt for confirmation before destroying resources diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.es.xlf index 8f53b6340f9..80980ddd2e5 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.es.xlf @@ -28,8 +28,8 @@ - Skip the confirmation prompt and proceed with the destroy operation - Skip the confirmation prompt and proceed with the destroy operation + Do not prompt for confirmation before destroying resources + Do not prompt for confirmation before destroying resources diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.fr.xlf index b41fd8f994e..28d40409a1e 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.fr.xlf @@ -28,8 +28,8 @@ - Skip the confirmation prompt and proceed with the destroy operation - Skip the confirmation prompt and proceed with the destroy operation + Do not prompt for confirmation before destroying resources + Do not prompt for confirmation before destroying resources diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.it.xlf index 26fb6ab4eeb..d91cc79ef37 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.it.xlf @@ -28,8 +28,8 @@ - Skip the confirmation prompt and proceed with the destroy operation - Skip the confirmation prompt and proceed with the destroy operation + Do not prompt for confirmation before destroying resources + Do not prompt for confirmation before destroying resources diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ja.xlf index ba79fe1c101..4ae1973eb14 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ja.xlf @@ -28,8 +28,8 @@ - Skip the confirmation prompt and proceed with the destroy operation - Skip the confirmation prompt and proceed with the destroy operation + Do not prompt for confirmation before destroying resources + Do not prompt for confirmation before destroying resources diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ko.xlf index 791532aa167..a39fc1e8432 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ko.xlf @@ -28,8 +28,8 @@ - Skip the confirmation prompt and proceed with the destroy operation - Skip the confirmation prompt and proceed with the destroy operation + Do not prompt for confirmation before destroying resources + Do not prompt for confirmation before destroying resources diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pl.xlf index 10cbd21d114..5544bb3eb0b 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pl.xlf @@ -28,8 +28,8 @@ - Skip the confirmation prompt and proceed with the destroy operation - Skip the confirmation prompt and proceed with the destroy operation + Do not prompt for confirmation before destroying resources + Do not prompt for confirmation before destroying resources diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pt-BR.xlf index eb9500dda82..cf72fd3e261 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pt-BR.xlf @@ -28,8 +28,8 @@ - Skip the confirmation prompt and proceed with the destroy operation - Skip the confirmation prompt and proceed with the destroy operation + Do not prompt for confirmation before destroying resources + Do not prompt for confirmation before destroying resources diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ru.xlf index 368567c6ecb..b29c95e6951 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ru.xlf @@ -28,8 +28,8 @@ - Skip the confirmation prompt and proceed with the destroy operation - Skip the confirmation prompt and proceed with the destroy operation + Do not prompt for confirmation before destroying resources + Do not prompt for confirmation before destroying resources diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.tr.xlf index 50d41bd89bd..f5e602271d2 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.tr.xlf @@ -28,8 +28,8 @@ - Skip the confirmation prompt and proceed with the destroy operation - Skip the confirmation prompt and proceed with the destroy operation + Do not prompt for confirmation before destroying resources + Do not prompt for confirmation before destroying resources diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hans.xlf index 3cbc1a384f8..cf634163dc5 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hans.xlf @@ -28,8 +28,8 @@ - Skip the confirmation prompt and proceed with the destroy operation - Skip the confirmation prompt and proceed with the destroy operation + Do not prompt for confirmation before destroying resources + Do not prompt for confirmation before destroying resources diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hant.xlf index 81f22522011..87a589c1146 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hant.xlf @@ -28,8 +28,8 @@ - Skip the confirmation prompt and proceed with the destroy operation - Skip the confirmation prompt and proceed with the destroy operation + Do not prompt for confirmation before destroying resources + Do not prompt for confirmation before destroying resources diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 71931e077d8..e8352637936 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -317,7 +317,7 @@ await discoveryTask.CompleteAsync( Intent = MessageIntent.Confirmation, ShowSecondaryButton = true, ShowDismiss = false, - PrimaryButtonText = "Yes, destroy", + PrimaryButtonText = "Destroy", SecondaryButtonText = "Cancel" }, context.CancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs index 24218e80b3f..5a90da32acf 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs @@ -182,6 +182,10 @@ await deployTask.CompleteAsync( else { ctx.Logger.LogInformation("Compose file '{Path}' no longer exists, skipping compose down. State preserved for manual cleanup.", savedComposeFilePath); + await ctx.ReportingStep.CompleteAsync( + $"Compose file no longer exists at '{savedComposeFilePath}'. Deployment state preserved for manual cleanup.", + CompletionState.Completed, + ctx.CancellationToken).ConfigureAwait(false); } }, DependsOnSteps = [WellKnownPipelineSteps.DestroyPrereq] @@ -385,7 +389,7 @@ private static async Task ConfirmDestroyAsync(PipelineStepContext context, strin Intent = MessageIntent.Confirmation, ShowSecondaryButton = true, ShowDismiss = false, - PrimaryButtonText = "Yes, destroy", + PrimaryButtonText = "Destroy", SecondaryButtonText = "Cancel" }, context.CancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs index bb99c0a5d76..33647b01a7d 100644 --- a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs +++ b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs @@ -584,6 +584,7 @@ private static async Task HelmUninstallAsync(PipelineStepContext context, string await uninstallTask.FailAsync( $"helm uninstall failed with exit code {exitCode}", cancellationToken: context.CancellationToken).ConfigureAwait(false); + throw new InvalidOperationException($"helm uninstall failed with exit code {exitCode}"); } else { @@ -626,7 +627,7 @@ private static async Task ConfirmDestroyAsync(PipelineStepContext context, strin Intent = MessageIntent.Confirmation, ShowSecondaryButton = true, ShowDismiss = false, - PrimaryButtonText = "Yes, destroy", + PrimaryButtonText = "Destroy", SecondaryButtonText = "Cancel" }, context.CancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Hosting/Pipelines/Internal/DeploymentStateManagerBase.cs b/src/Aspire.Hosting/Pipelines/Internal/DeploymentStateManagerBase.cs index 5d40834115f..772be57b004 100644 --- a/src/Aspire.Hosting/Pipelines/Internal/DeploymentStateManagerBase.cs +++ b/src/Aspire.Hosting/Pipelines/Internal/DeploymentStateManagerBase.cs @@ -309,7 +309,7 @@ private static void SetNestedPropertyValue(JsonObject root, string path, JsonObj } /// - public Task ClearAllStateAsync(CancellationToken cancellationToken = default) + public async Task ClearAllStateAsync(CancellationToken cancellationToken = default) { if (StateFilePath is string stateFilePath && File.Exists(stateFilePath)) { @@ -317,14 +317,19 @@ public Task ClearAllStateAsync(CancellationToken cancellationToken = default) logger.LogInformation("Deployment state cleared: {Path}", stateFilePath); } - // Reset in-memory state - lock (_sectionsLock) + await _stateLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + lock (_sectionsLock) + { + _sections.Clear(); + } + _state = null; + _isStateLoaded = false; + } + finally { - _sections.Clear(); + _stateLock.Release(); } - _state = null; - _isStateLoaded = false; - - return Task.CompletedTask; } } From 99b8d871f8e64ded6f8dbeb46f66ab4f6723c45b Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 20:34:38 -0700 Subject: [PATCH 27/35] Use sentence case for destroy status messages Change 'DESTROY COMPLETED'/'DESTROY FAILED' to 'Destroy completed'/ 'Destroy failed' per review feedback (JamesNK). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Resources/DestroyCommandStrings.Designer.cs | 4 ++-- src/Aspire.Cli/Resources/DestroyCommandStrings.resx | 4 ++-- src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.cs.xlf | 8 ++++---- src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.de.xlf | 8 ++++---- src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.es.xlf | 8 ++++---- src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.fr.xlf | 8 ++++---- src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.it.xlf | 8 ++++---- src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ja.xlf | 8 ++++---- src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ko.xlf | 8 ++++---- src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pl.xlf | 8 ++++---- .../Resources/xlf/DestroyCommandStrings.pt-BR.xlf | 8 ++++---- src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ru.xlf | 8 ++++---- src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.tr.xlf | 8 ++++---- .../Resources/xlf/DestroyCommandStrings.zh-Hans.xlf | 8 ++++---- .../Resources/xlf/DestroyCommandStrings.zh-Hant.xlf | 8 ++++---- 15 files changed, 56 insertions(+), 56 deletions(-) diff --git a/src/Aspire.Cli/Resources/DestroyCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/DestroyCommandStrings.Designer.cs index b4c227295be..2d8ae6319d4 100644 --- a/src/Aspire.Cli/Resources/DestroyCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/DestroyCommandStrings.Designer.cs @@ -88,7 +88,7 @@ public static string OutputPathArgumentDescription { } /// - /// Looks up a localized string similar to DESTROY COMPLETED. + /// Looks up a localized string similar to Destroy completed. /// public static string OperationCompletedPrefix { get { @@ -97,7 +97,7 @@ public static string OperationCompletedPrefix { } /// - /// Looks up a localized string similar to DESTROY FAILED. + /// Looks up a localized string similar to Destroy failed. /// public static string OperationFailedPrefix { get { diff --git a/src/Aspire.Cli/Resources/DestroyCommandStrings.resx b/src/Aspire.Cli/Resources/DestroyCommandStrings.resx index 2bda5165252..c98f9d4bbbe 100644 --- a/src/Aspire.Cli/Resources/DestroyCommandStrings.resx +++ b/src/Aspire.Cli/Resources/DestroyCommandStrings.resx @@ -127,10 +127,10 @@ The destroy operation was canceled. - DESTROY COMPLETED + Destroy completed - DESTROY FAILED + Destroy failed Do not prompt for confirmation before destroying resources diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.cs.xlf index a9f400ed2e7..9b1b2aaf2c1 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.cs.xlf @@ -13,13 +13,13 @@ - DESTROY COMPLETED - DESTROY COMPLETED + Destroy completed + Destroy completed - DESTROY FAILED - DESTROY FAILED + Destroy failed + Destroy failed diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.de.xlf index 02798caedfa..90aec8b6558 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.de.xlf @@ -13,13 +13,13 @@ - DESTROY COMPLETED - DESTROY COMPLETED + Destroy completed + Destroy completed - DESTROY FAILED - DESTROY FAILED + Destroy failed + Destroy failed diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.es.xlf index 80980ddd2e5..cbfac6dbc82 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.es.xlf @@ -13,13 +13,13 @@ - DESTROY COMPLETED - DESTROY COMPLETED + Destroy completed + Destroy completed - DESTROY FAILED - DESTROY FAILED + Destroy failed + Destroy failed diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.fr.xlf index 28d40409a1e..beb5d05a030 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.fr.xlf @@ -13,13 +13,13 @@ - DESTROY COMPLETED - DESTROY COMPLETED + Destroy completed + Destroy completed - DESTROY FAILED - DESTROY FAILED + Destroy failed + Destroy failed diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.it.xlf index d91cc79ef37..c3401bc54d4 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.it.xlf @@ -13,13 +13,13 @@ - DESTROY COMPLETED - DESTROY COMPLETED + Destroy completed + Destroy completed - DESTROY FAILED - DESTROY FAILED + Destroy failed + Destroy failed diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ja.xlf index 4ae1973eb14..69f47e07da9 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ja.xlf @@ -13,13 +13,13 @@ - DESTROY COMPLETED - DESTROY COMPLETED + Destroy completed + Destroy completed - DESTROY FAILED - DESTROY FAILED + Destroy failed + Destroy failed diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ko.xlf index a39fc1e8432..9693e4695e9 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ko.xlf @@ -13,13 +13,13 @@ - DESTROY COMPLETED - DESTROY COMPLETED + Destroy completed + Destroy completed - DESTROY FAILED - DESTROY FAILED + Destroy failed + Destroy failed diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pl.xlf index 5544bb3eb0b..8b95acaff7e 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pl.xlf @@ -13,13 +13,13 @@ - DESTROY COMPLETED - DESTROY COMPLETED + Destroy completed + Destroy completed - DESTROY FAILED - DESTROY FAILED + Destroy failed + Destroy failed diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pt-BR.xlf index cf72fd3e261..2fd39a6800d 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pt-BR.xlf @@ -13,13 +13,13 @@ - DESTROY COMPLETED - DESTROY COMPLETED + Destroy completed + Destroy completed - DESTROY FAILED - DESTROY FAILED + Destroy failed + Destroy failed diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ru.xlf index b29c95e6951..3fe68554b97 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ru.xlf @@ -13,13 +13,13 @@ - DESTROY COMPLETED - DESTROY COMPLETED + Destroy completed + Destroy completed - DESTROY FAILED - DESTROY FAILED + Destroy failed + Destroy failed diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.tr.xlf index f5e602271d2..68a10828607 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.tr.xlf @@ -13,13 +13,13 @@ - DESTROY COMPLETED - DESTROY COMPLETED + Destroy completed + Destroy completed - DESTROY FAILED - DESTROY FAILED + Destroy failed + Destroy failed diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hans.xlf index cf634163dc5..dbc09d2bc57 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hans.xlf @@ -13,13 +13,13 @@ - DESTROY COMPLETED - DESTROY COMPLETED + Destroy completed + Destroy completed - DESTROY FAILED - DESTROY FAILED + Destroy failed + Destroy failed diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hant.xlf index 87a589c1146..c04c4da55a9 100644 --- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hant.xlf @@ -13,13 +13,13 @@ - DESTROY COMPLETED - DESTROY COMPLETED + Destroy completed + Destroy completed - DESTROY FAILED - DESTROY FAILED + Destroy failed + Destroy failed From a583f508fa63c81abcf20ebbca94e7728302a791 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 20:43:08 -0700 Subject: [PATCH 28/35] Add regression test for Helm uninstall failure preserving state Verifies that when helm uninstall exits non-zero, deployment state is preserved so the user can retry aspire destroy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../KubernetesDeployTests.cs | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs index e565c7f0415..4819e4c1742 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs @@ -1390,6 +1390,7 @@ private sealed class FakeHelmRunner : IHelmRunner { public bool WasUninstallCalled { get; private set; } public string? LastArguments { get; private set; } + public int ExitCode { get; set; } public Task RunAsync( string arguments, @@ -1403,7 +1404,46 @@ public Task RunAsync( { WasUninstallCalled = true; } - return Task.FromResult(0); + return Task.FromResult(ExitCode); } } + + [Fact] + public async Task DestroyHelm_WhenUninstallFails_PreservesState() + { + using var tempDir = new TestTempDirectory(); + + var fakeHelm = new FakeHelmRunner { ExitCode = 1 }; + var stateManager = new InMemoryDeploymentStateManager(); + stateManager.SetSection("Helm:env", new JsonObject + { + ["ReleaseName"] = "my-release", + ["Namespace"] = "my-namespace" + }); + + var mockActivityReporter = new TestPipelineActivityReporter(output); + var builder = TestDistributedApplicationBuilder.Create( + DistributedApplicationOperation.Publish, + tempDir.Path, + step: WellKnownPipelineSteps.Destroy); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(mockActivityReporter); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(fakeHelm); + builder.Services.Configure(o => o.SkipConfirmation = true); + + builder.AddKubernetesEnvironment("env"); + builder.AddContainer("api", "myimage"); + + using var app = builder.Build(); + await app.RunAsync(); + + // Verify helm uninstall was attempted + Assert.True(fakeHelm.WasUninstallCalled); + + // Verify state was NOT deleted (preserved for retry) + var stateSection = await stateManager.AcquireSectionAsync("Helm:env"); + Assert.Equal("my-release", stateSection.Data["ReleaseName"]?.ToString()); + } } From 38e54506bd884a31ebba6b1a03ace6bf9576fe4f Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 12 Apr 2026 21:04:22 -0700 Subject: [PATCH 29/35] Address remaining review feedback - Lowercase non-proper-noun words in prompt titles (JamesNK) - Revert --force back to --yes pending naming discussion - Fix zero-width character introduced by sed in test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs | 2 +- src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs | 2 +- .../Deployment/HelmDeploymentEngine.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index e8352637936..b5cd863f98b 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -310,7 +310,7 @@ await discoveryTask.CompleteAsync( : $"Delete resource group '{resourceGroupName}'? This action cannot be undone."; var result = await interactionService.PromptNotificationAsync( - "Destroy Azure Resources", + "Destroy Azure resources", confirmMessage, new NotificationInteractionOptions { diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs index 5a90da32acf..3f54122a404 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs @@ -382,7 +382,7 @@ private static async Task ConfirmDestroyAsync(PipelineStepContext context, strin } var result = await interactionService.PromptNotificationAsync( - "Destroy Environment", + "Destroy environment", message, new NotificationInteractionOptions { diff --git a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs index 33647b01a7d..34230c1797c 100644 --- a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs +++ b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs @@ -620,7 +620,7 @@ private static async Task ConfirmDestroyAsync(PipelineStepContext context, strin } var result = await interactionService.PromptNotificationAsync( - "Destroy Environment", + "Destroy environment", message, new NotificationInteractionOptions { From c6a7600394883e1e9f9754a051c1bfeb46bb77f9 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 13 Apr 2026 00:45:56 -0700 Subject: [PATCH 30/35] Fix compose destroy failing on stale build contexts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker compose down validates the compose file before executing, which fails when build contexts referenced in the file no longer exist on disk. This happens in normal deploy→destroy flows when containers were built from temporary contexts. Fix: destroy now uses project-name-only mode (no -f flag) for compose down. The project name from saved deployment state is sufficient — compose looks up running containers by project label. Also make ComposeFilePath optional in ComposeOperationContext so callers can opt into project-name-only mode. Test updated to verify ComposeFilePath is null in destroy context. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DockerComposeEnvironmentResource.cs | 56 ++++++++----------- .../Publishing/ComposeOperationContext.cs | 3 +- .../Publishing/ContainerRuntimeBase.cs | 4 +- .../DockerComposeTests.cs | 12 ++-- .../Publishing/FakeContainerRuntime.cs | 2 + 5 files changed, 37 insertions(+), 40 deletions(-) diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs index 3f54122a404..65dd51c963c 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs @@ -148,45 +148,35 @@ await ctx.ReportingStep.CompleteAsync( await ConfirmDestroyAsync(ctx, $"Shut down Docker Compose environment '{Name}'? This will stop and remove all containers, networks, and volumes.").ConfigureAwait(false); // Use saved state to build the compose context — don't recompute from current model - if (File.Exists(savedComposeFilePath)) - { - var savedOutputPath = stateSection.Data["OutputPath"]?.ToString() ?? Path.GetDirectoryName(savedComposeFilePath)!; - var savedProjectName = stateSection.Data["ProjectName"]?.ToString() ?? GetDockerComposeProjectName(ctx, this); - - var runtime = await ctx.Services.GetRequiredService().ResolveAsync(ctx.CancellationToken).ConfigureAwait(false); - - var composeContext = new ComposeOperationContext - { - ComposeFilePath = savedComposeFilePath, - ProjectName = savedProjectName, - WorkingDirectory = savedOutputPath - }; - - var deployTask = await ctx.ReportingStep.CreateTaskAsync( - new MarkdownString($"Running compose down for **{Name}** using **{runtime.Name}**"), - ctx.CancellationToken).ConfigureAwait(false); - await using (deployTask.ConfigureAwait(false)) - { - await runtime.ComposeDownAsync(composeContext, ctx.CancellationToken).ConfigureAwait(false); - await deployTask.CompleteAsync( - new MarkdownString($"Compose shutdown complete for **{Name}** ({runtime.Name})"), - CompletionState.Completed, - ctx.CancellationToken).ConfigureAwait(false); - } + // Only use the project name for down — the compose file may not be valid for down + // (e.g., services with build contexts that no longer exist) + var savedOutputPath = stateSection.Data["OutputPath"]?.ToString() ?? Path.GetDirectoryName(savedComposeFilePath)!; + var savedProjectName = stateSection.Data["ProjectName"]?.ToString() ?? GetDockerComposeProjectName(ctx, this); - ctx.Summary.Add("🗑️ Compose", Name); + var runtime = await ctx.Services.GetRequiredService().ResolveAsync(ctx.CancellationToken).ConfigureAwait(false); - // Clean up deployment state only after successful teardown - await deploymentStateManager.DeleteSectionAsync(stateSection, ctx.CancellationToken).ConfigureAwait(false); - } - else + var composeContext = new ComposeOperationContext { - ctx.Logger.LogInformation("Compose file '{Path}' no longer exists, skipping compose down. State preserved for manual cleanup.", savedComposeFilePath); - await ctx.ReportingStep.CompleteAsync( - $"Compose file no longer exists at '{savedComposeFilePath}'. Deployment state preserved for manual cleanup.", + ProjectName = savedProjectName, + WorkingDirectory = savedOutputPath + }; + + var deployTask = await ctx.ReportingStep.CreateTaskAsync( + new MarkdownString($"Running compose down for **{Name}** using **{runtime.Name}**"), + ctx.CancellationToken).ConfigureAwait(false); + await using (deployTask.ConfigureAwait(false)) + { + await runtime.ComposeDownAsync(composeContext, ctx.CancellationToken).ConfigureAwait(false); + await deployTask.CompleteAsync( + new MarkdownString($"Compose shutdown complete for **{Name}** ({runtime.Name})"), CompletionState.Completed, ctx.CancellationToken).ConfigureAwait(false); } + + ctx.Summary.Add("🗑️ Compose", Name); + + // Clean up deployment state only after successful teardown + await deploymentStateManager.DeleteSectionAsync(stateSection, ctx.CancellationToken).ConfigureAwait(false); }, DependsOnSteps = [WellKnownPipelineSteps.DestroyPrereq] }; diff --git a/src/Aspire.Hosting/Publishing/ComposeOperationContext.cs b/src/Aspire.Hosting/Publishing/ComposeOperationContext.cs index b888e16d3d4..7b657835860 100644 --- a/src/Aspire.Hosting/Publishing/ComposeOperationContext.cs +++ b/src/Aspire.Hosting/Publishing/ComposeOperationContext.cs @@ -13,8 +13,9 @@ public sealed class ComposeOperationContext { /// /// Gets the path to the Docker Compose YAML file. + /// When null, compose operations will use the project name only without referencing a file. /// - public required string ComposeFilePath { get; init; } + public string? ComposeFilePath { get; init; } /// /// Gets the compose project name used for resource isolation. diff --git a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs index 43fb9cdc9c7..4bb746ecaa8 100644 --- a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs +++ b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs @@ -530,7 +530,9 @@ private static ComposeServiceInfo MapDockerComposeEntry(DockerComposePsEntry ent /// private static string BuildComposeArguments(ComposeOperationContext context) { - var arguments = $"compose -f \"{context.ComposeFilePath}\" --project-name \"{context.ProjectName}\""; + var arguments = context.ComposeFilePath is not null + ? $"compose -f \"{context.ComposeFilePath}\" --project-name \"{context.ProjectName}\"" + : $"compose --project-name \"{context.ProjectName}\""; if (context.EnvFilePath is not null && File.Exists(context.EnvFilePath)) { diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs index 188126adf05..e19ef6c80e7 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs @@ -905,17 +905,13 @@ public async Task DestroyCompose_WithState_RunsComposeDown() { using var tempDir = new TestTempDirectory(); - // Create a fake compose file so destroy finds it - var composeFilePath = Path.Combine(tempDir.Path, "docker-compose.yaml"); - await File.WriteAllTextAsync(composeFilePath, "version: '3'"); - var fakeRuntime = new FakeContainerRuntime(); var stateManager = new InMemoryDeploymentStateManager(); stateManager.SetSection("DockerCompose:env", new System.Text.Json.Nodes.JsonObject { ["OutputPath"] = tempDir.Path, ["ProjectName"] = "aspire-env-test", - ["ComposeFilePath"] = composeFilePath + ["ComposeFilePath"] = Path.Combine(tempDir.Path, "docker-compose.yaml") }); var mockActivityReporter = new TestPipelineActivityReporter(output); @@ -936,6 +932,12 @@ public async Task DestroyCompose_WithState_RunsComposeDown() // Verify compose down was called Assert.True(fakeRuntime.WasComposeDownCalled); + // Verify destroy uses project-name-only mode (no compose file) + // so it doesn't fail on stale build contexts in the compose file + Assert.NotNull(fakeRuntime.LastComposeDownContext); + Assert.Null(fakeRuntime.LastComposeDownContext.ComposeFilePath); + Assert.Equal("aspire-env-test", fakeRuntime.LastComposeDownContext.ProjectName); + // Verify the destroy step ran var createdSteps = mockActivityReporter.CreatedSteps; Assert.Contains(createdSteps, s => s.Contains("destroy-compose-", StringComparison.OrdinalIgnoreCase)); diff --git a/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs b/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs index 8e95cd9e90b..c87eb242129 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs @@ -22,6 +22,7 @@ public sealed class FakeContainerRuntime(bool shouldFail = false, bool isRunning public bool WasBuildImageCalled { get; private set; } public bool WasLoginToRegistryCalled { get; private set; } public bool WasComposeDownCalled { get; private set; } + public ComposeOperationContext? LastComposeDownContext { get; private set; } public ConcurrentBag<(string localImageName, string targetImageName)> TagImageCalls { get; } = []; public ConcurrentBag RemoveImageCalls { get; } = []; public ConcurrentBag PushImageCalls { get; } = []; @@ -117,6 +118,7 @@ public Task ComposeUpAsync(ComposeOperationContext context, CancellationToken ca public Task ComposeDownAsync(ComposeOperationContext context, CancellationToken cancellationToken) { WasComposeDownCalled = true; + LastComposeDownContext = context; if (shouldFail) { throw new DistributedApplicationException("Fake container runtime is configured to fail"); From 052bb72f04a63de4b39c8a6041fbc021072f9a25 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 13 Apr 2026 01:03:32 -0700 Subject: [PATCH 31/35] Wait for Azure resource group deletion to complete Change from WaitUntil.Started to WaitUntil.Completed so destroy actually waits for the RG to be deleted before reporting success. This avoids the confusing 'deletion initiated' message and prevents the stale-state problem where immediate redeploy fails. Also closes #16100 since we now wait for completion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index b5cd863f98b..ddb9710d4dd 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -337,14 +337,14 @@ await discoveryTask.CompleteAsync( try { - await resourceGroup.DeleteAsync(WaitUntil.Started, context.CancellationToken).ConfigureAwait(false); + await resourceGroup.DeleteAsync(WaitUntil.Completed, context.CancellationToken).ConfigureAwait(false); var portalUrl = AzurePortalUrls.GetResourceGroupUrl(subscriptionId, resourceGroupName, subscription.TenantId); context.Summary.Add("🗑️ Resource Group", new MarkdownString($"[{resourceGroupName}]({portalUrl})")); context.Summary.Add("🔑 Subscription", subscriptionId); await deleteTask.CompleteAsync( - new MarkdownString($"Resource group **{resourceGroupName}** deletion initiated successfully"), + new MarkdownString($"Resource group **{resourceGroupName}** deleted"), CompletionState.Completed, context.CancellationToken).ConfigureAwait(false); } From 27597742e9df38516c0b94fdf4f5c7b9c49264f8 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 13 Apr 2026 01:07:17 -0700 Subject: [PATCH 32/35] Fix Helm double task completion and improve Azure delete messaging - Remove redundant FailAsync before throw in HelmUninstallAsync to avoid completing the task twice (JamesNK) - Fix 8-space indent in ProvisioningTestHelpers (JamesNK) - Revert Azure delete to WaitUntil.Started (faster for E2E tests) with honest messaging: 'deletion in progress, monitor in Azure portal' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs | 4 ++-- .../Deployment/HelmDeploymentEngine.cs | 3 --- tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index ddb9710d4dd..f4b39f75300 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -337,14 +337,14 @@ await discoveryTask.CompleteAsync( try { - await resourceGroup.DeleteAsync(WaitUntil.Completed, context.CancellationToken).ConfigureAwait(false); + await resourceGroup.DeleteAsync(WaitUntil.Started, context.CancellationToken).ConfigureAwait(false); var portalUrl = AzurePortalUrls.GetResourceGroupUrl(subscriptionId, resourceGroupName, subscription.TenantId); context.Summary.Add("🗑️ Resource Group", new MarkdownString($"[{resourceGroupName}]({portalUrl})")); context.Summary.Add("🔑 Subscription", subscriptionId); await deleteTask.CompleteAsync( - new MarkdownString($"Resource group **{resourceGroupName}** deleted"), + new MarkdownString($"Resource group **{resourceGroupName}** deletion in progress. Monitor in the [Azure portal]({portalUrl})."), CompletionState.Completed, context.CancellationToken).ConfigureAwait(false); } diff --git a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs index 34230c1797c..ed04194be43 100644 --- a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs +++ b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs @@ -581,9 +581,6 @@ private static async Task HelmUninstallAsync(PipelineStepContext context, string if (exitCode != 0) { - await uninstallTask.FailAsync( - $"helm uninstall failed with exit code {exitCode}", - cancellationToken: context.CancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"helm uninstall failed with exit code {exitCode}"); } else diff --git a/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs b/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs index 520e9a1b359..8bdfaa5a515 100644 --- a/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs +++ b/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs @@ -664,7 +664,7 @@ public Task SaveSectionAsync(DeploymentStateSection section, CancellationToken c return Task.CompletedTask; } - public Task ClearAllStateAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task ClearAllStateAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; } internal sealed class TestUserPrincipalProvider : IUserPrincipalProvider From 9125cd16cc1048bd4df4da250a6c50efbf289685 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 13 Apr 2026 01:10:01 -0700 Subject: [PATCH 33/35] Add deletion status to destroy summary Show 'Deletion in progress. Monitor in the Azure portal.' in the pipeline summary so the async nature of RG deletion is visible even after the pipeline output scrolls away. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index f4b39f75300..7c34a49d03e 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -342,6 +342,7 @@ await discoveryTask.CompleteAsync( var portalUrl = AzurePortalUrls.GetResourceGroupUrl(subscriptionId, resourceGroupName, subscription.TenantId); context.Summary.Add("🗑️ Resource Group", new MarkdownString($"[{resourceGroupName}]({portalUrl})")); context.Summary.Add("🔑 Subscription", subscriptionId); + context.Summary.Add("⏳ Status", new MarkdownString($"Deletion in progress. Monitor in the [Azure portal]({portalUrl}).")); await deleteTask.CompleteAsync( new MarkdownString($"Resource group **{resourceGroupName}** deletion in progress. Monitor in the [Azure portal]({portalUrl})."), From 72fe4d7bc70c95f49855b38fe9446e5a78866c05 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 13 Apr 2026 01:14:02 -0700 Subject: [PATCH 34/35] Show portal URL inline in destroy status summary Make the monitoring link unmissable by showing the full URL rather than hiding it behind 'Azure portal' link text. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 7c34a49d03e..65d699bcb7a 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -342,7 +342,7 @@ await discoveryTask.CompleteAsync( var portalUrl = AzurePortalUrls.GetResourceGroupUrl(subscriptionId, resourceGroupName, subscription.TenantId); context.Summary.Add("🗑️ Resource Group", new MarkdownString($"[{resourceGroupName}]({portalUrl})")); context.Summary.Add("🔑 Subscription", subscriptionId); - context.Summary.Add("⏳ Status", new MarkdownString($"Deletion in progress. Monitor in the [Azure portal]({portalUrl}).")); + context.Summary.Add("⏳ Status", new MarkdownString($"Deletion in progress. Monitor [here]({portalUrl})")); await deleteTask.CompleteAsync( new MarkdownString($"Resource group **{resourceGroupName}** deletion in progress. Monitor in the [Azure portal]({portalUrl})."), From 84ce8cfbc462da270d497e6786bc1a0e23e3a348 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 13 Apr 2026 08:40:04 -0700 Subject: [PATCH 35/35] Address review feedback from eerhardt - Scope discoveryTask await using block so it disposes before confirmation prompt - Inline Docker compose destroy confirmation message into ConfirmDestroyAsync Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AzureEnvironmentResource.cs | 68 ++++++++++--------- .../DockerComposeEnvironmentResource.cs | 6 +- 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 65d699bcb7a..169f271df5d 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -253,52 +253,54 @@ await context.ReportingStep.CompleteAsync( } // Enumerate resources in the resource group so the user can see what will be destroyed - var discoveryTask = await context.ReportingStep.CreateTaskAsync( - new MarkdownString($"Discovering resources in **{resourceGroupName}**"), - context.CancellationToken).ConfigureAwait(false); - var resources = new List<(string Name, string ResourceType)>(); - await using var _ = discoveryTask.ConfigureAwait(false); - try { - await foreach (var resource in resourceGroup.GetResourcesAsync(context.CancellationToken).ConfigureAwait(false)) - { - resources.Add(resource); - } + var discoveryTask = await context.ReportingStep.CreateTaskAsync( + new MarkdownString($"Discovering resources in **{resourceGroupName}**"), + context.CancellationToken).ConfigureAwait(false); + await using var _ = discoveryTask.ConfigureAwait(false); - if (resources.Count == 0) - { - await discoveryTask.CompleteAsync( - new MarkdownString($"Resource group **{resourceGroupName}** is empty"), - CompletionState.Completed, - context.CancellationToken).ConfigureAwait(false); - } - else + try { - foreach (var (name, type) in resources) + await foreach (var resource in resourceGroup.GetResourcesAsync(context.CancellationToken).ConfigureAwait(false)) { - var shortType = type.StartsWith("Microsoft.", StringComparison.OrdinalIgnoreCase) - ? type["Microsoft.".Length..] - : type; - context.Logger.LogInformation(" {Type}: {Name}", shortType, name); + resources.Add(resource); } + if (resources.Count == 0) + { + await discoveryTask.CompleteAsync( + new MarkdownString($"Resource group **{resourceGroupName}** is empty"), + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); + } + else + { + foreach (var (name, type) in resources) + { + var shortType = type.StartsWith("Microsoft.", StringComparison.OrdinalIgnoreCase) + ? type["Microsoft.".Length..] + : type; + context.Logger.LogInformation(" {Type}: {Name}", shortType, name); + } + + await discoveryTask.CompleteAsync( + new MarkdownString($"Found **{resources.Count}** resource(s) in **{resourceGroupName}**"), + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); + } + } + catch (Exception ex) + { + // Non-fatal — proceed with deletion even if enumeration fails + context.Logger.LogWarning(ex, "Failed to enumerate resources in resource group '{ResourceGroupName}'", resourceGroupName); await discoveryTask.CompleteAsync( - new MarkdownString($"Found **{resources.Count}** resource(s) in **{resourceGroupName}**"), + "Could not enumerate resources (will proceed with deletion)", CompletionState.Completed, context.CancellationToken).ConfigureAwait(false); } } - catch (Exception ex) - { - // Non-fatal — proceed with deletion even if enumeration fails - context.Logger.LogWarning(ex, "Failed to enumerate resources in resource group '{ResourceGroupName}'", resourceGroupName); - await discoveryTask.CompleteAsync( - "Could not enumerate resources (will proceed with deletion)", - CompletionState.Completed, - context.CancellationToken).ConfigureAwait(false); - } // Confirm destruction with the user (unless --yes was specified) if (!options.Value.SkipConfirmation) diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs index 65dd51c963c..4b85965916a 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs @@ -145,7 +145,7 @@ await ctx.ReportingStep.CompleteAsync( return; } - await ConfirmDestroyAsync(ctx, $"Shut down Docker Compose environment '{Name}'? This will stop and remove all containers, networks, and volumes.").ConfigureAwait(false); + await ConfirmDestroyAsync(ctx, Name).ConfigureAwait(false); // Use saved state to build the compose context — don't recompute from current model // Only use the project name for down — the compose file may not be valid for down @@ -357,7 +357,7 @@ await deployTask.CompleteAsync( } } - private static async Task ConfirmDestroyAsync(PipelineStepContext context, string message) + private static async Task ConfirmDestroyAsync(PipelineStepContext context, string environmentName) { var options = context.Services.GetRequiredService>(); @@ -373,7 +373,7 @@ private static async Task ConfirmDestroyAsync(PipelineStepContext context, strin var result = await interactionService.PromptNotificationAsync( "Destroy environment", - message, + $"Shut down Docker Compose environment '{environmentName}'? This will stop and remove all containers, networks, and volumes.", new NotificationInteractionOptions { Intent = MessageIntent.Confirmation,