Skip to content

Add aspire destroy command for tearing down deployed environments#16097

Open
davidfowl wants to merge 29 commits intomainfrom
davidfowl/aspire-destroy
Open

Add aspire destroy command for tearing down deployed environments#16097
davidfowl wants to merge 29 commits intomainfrom
davidfowl/aspire-destroy

Conversation

@davidfowl
Copy link
Copy Markdown
Contributor

Description

Add aspire destroy — a new top-level CLI command that tears down previously deployed Aspire environments. Completes the deployment lifecycle: aspire publish → aspire deploy → aspire destroy.

What it does

Each compute environment implements its own destroy step with contextual confirmation:

  • Azure (ACA/App Service): discovers resources in the resource group via ARM, shows them to the user, confirms, then deletes the resource group
  • Docker Compose: confirms, then runs docker compose down using persisted deployment state
  • Kubernetes (Helm): confirms, then runs helm uninstall using persisted release name and namespace

Key design decisions

  • Per-environment confirmation prompts with full context (RG name + resource count, Helm release + namespace, Compose environment name). --yes/-y skips prompts.
  • Deployment state persistence — Docker Compose and Helm now save minimal state during deploy (output path, project name, release name, namespace) so destroy targets exactly what was deployed, even if the AppHost model changed.
  • Layered pipeline steps — each environment has a destroy-{env} step (confirm + action) and a standalone action step (docker-compose-down, helm-uninstall) callable via aspire do without confirmation.
  • Non-interactive fail-fast — running without --yes in non-interactive mode throws immediately instead of silently proceeding.
  • State cleanup — deployment state file is cleared after successful destroy via IDeploymentStateManager.ClearAllStateAsync.

New abstractions

  • WellKnownPipelineSteps.Destroy / DestroyPrereq — pipeline aggregation steps
  • PipelineOptions.SkipConfirmation — forwarded from CLI --yes flag
  • IDeploymentStateManager.ClearAllStateAsync() — centralized state cleanup
  • IResourceGroupResource.DeleteAsync() / GetResourcesAsync() — ARM abstractions for destroy
  • IHelmRunner — testable abstraction for Helm CLI operations

Validation

  • Manual testing: Azure ACA deploy→destroy (interactive + --yes), Docker Compose deploy→destroy (interactive + --yes)
  • Unit tests: 5 CLI command tests, 3 Azure destroy pipeline tests (with state, no state, non-interactive), 2 Docker Compose destroy tests + 1 deploy→destroy roundtrip, 2 Helm destroy tests, pipeline wiring tests
  • E2E test updates: 17 deployment test files updated to use aspire destroy --yes via shared AspireDestroyAsync helper

Fixes #13013

Checklist

  • Is this feature complete?
    • Yes. Ready to ship.
    • No. Follow-up changes expected.
  • Are you including unit tests for the changes and scenario tests if relevant?
    • Yes
    • No
  • Did you add public API?
    • Yes
      • If yes, did you have an API Review for it?
        • Yes
        • No
      • Did you add <remarks /> and <code /> elements on your triple slash comments?
        • Yes
        • No
    • No
  • Does the change make any security assumptions or guarantees?
    • Yes
    • No
  • Does the change require an update in our Aspire docs?

davidfowl and others added 21 commits April 12, 2026 09:02
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>
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>
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>
- 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>
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>
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>
- 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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
Copilot AI review requested due to automatic review settings April 12, 2026 22:23
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 12, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 16097

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 16097"

Suppress CP0006 for ClearAllStateAsync added to IDeploymentStateManager
and Compose methods added to IContainerRuntime.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Removes redundant Microsoft.Extensions.DependencyInjection using
(only Extensions variant needed for TryAddSingleton).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@davidfowl
Copy link
Copy Markdown
Contributor Author

/deployment-tests

davidfowl and others added 2 commits April 12, 2026 16:11
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>
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>
@davidfowl
Copy link
Copy Markdown
Contributor Author

/deployment-tests

dockerComposeUpStep.RequiredBy(WellKnownPipelineSteps.Deploy);
steps.Add(dockerComposeUpStep);

var dockerComposeDestroyStep = new PipelineStep
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you plan on keeping the existing down step as a less destructive step?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's just as destructive, this just prompts before doing the downing

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm, as far as a I remember the existing down command didn't clear out deployment state or the existing project files. It was just a redirect to docker compose down.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes, that is still there as a separate step.

Copy link
Copy Markdown
Member

@JamesNK JamesNK left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the aspire destroy implementation across CLI, Azure, Docker Compose, and Helm environments. Found 3 issues:\n\n- 1 bug (Helm): Deployment state deleted even when helm uninstall fails, preventing retry\n- 1 bug (Hosting): ClearAllStateAsync modifies shared fields without holding _stateLock\n- 1 bug (Docker Compose): Missing user-visible feedback when compose file no longer exists on disk

davidfowl and others added 3 commits April 12, 2026 20:31
- 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>
Change 'DESTROY COMPLETED'/'DESTROY FAILED' to 'Destroy completed'/
'Destroy failed' per review feedback (JamesNK).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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>
};

var deployTask = await ctx.ReportingStep.CreateTaskAsync(
new MarkdownString($"Running compose down for **{Name}** using **{runtime.Name}**"),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could the names in any of these markdownstrings contain markdown content? Do they need to be escaped?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good rule, I'm not sure we're consistent though, this is new. It's a resource name.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resource names are validated to ASCII letters/digits/hyphens by ModelName, so they can't contain markdown special characters. RG names from Azure state could theoretically have issues but this is a pre-existing pattern across all pipeline steps. Filed as a follow-up to audit all MarkdownString usage.

// TODO: Rename this to something related to deployment state
{ "--clear-cache", "Pipeline:ClearCache" },

{ "--yes", "Pipeline:SkipConfirmation" },
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like --yes as a name. It's so generic and could mean anything to the person who reads it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. --force isn't great either — it implies overriding safety checks, not just skipping a prompt.

Options:

Flag Precedent Pros Cons
--yes / -y apt-get -y, npm init -y, az group delete --yes Well-understood "answer yes to prompts" Generic, as you said
--force / -f azd down --force, docker system prune -f Common Implies behavior change, not just prompt skip
--no-confirm nuget push --no-confirm Precise meaning Verbose
--skip-confirmation Very clear Too long

I'd lean toward --yes since it's the most widely understood for this exact use case. WDYT?

- 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>
@github-actions
Copy link
Copy Markdown
Contributor

🎬 CLI E2E Test Recordings — 68 recordings uploaded (commit 38e5450)

View recordings
Test Recording
AddPackageInteractiveWhileAppHostRunningDetached ▶️ View Recording
AddPackageWhileAppHostRunningDetached ▶️ View Recording
AgentCommands_AllHelpOutputs_AreCorrect ▶️ View Recording
AgentInitCommand_DefaultSelection_InstallsSkillOnly ▶️ View Recording
AgentInitCommand_MigratesDeprecatedConfig ▶️ View Recording
AllPublishMethodsBuildDockerImages ▶️ View Recording
AspireAddPackageVersionToDirectoryPackagesProps ▶️ View Recording
AspireUpdateRemovesAppHostPackageVersionFromDirectoryPackagesProps ▶️ View Recording
Banner_DisplayedOnFirstRun ▶️ View Recording
Banner_DisplayedWithExplicitFlag ▶️ View Recording
Banner_NotDisplayedWithNoLogoFlag ▶️ View Recording
CertificatesClean_RemovesCertificates ▶️ View Recording
CertificatesTrust_WithNoCert_CreatesAndTrustsCertificate ▶️ View Recording
CertificatesTrust_WithUntrustedCert_TrustsCertificate ▶️ View Recording
ConfigSetGet_CreatesNestedJsonFormat ▶️ View Recording
CreateAndRunAspireStarterProject ▶️ View Recording
CreateAndRunAspireStarterProjectWithBundle ▶️ View Recording
CreateAndRunEmptyAppHostProject ▶️ View Recording
CreateAndRunJavaEmptyAppHostProject ▶️ View Recording
CreateAndRunJsReactProject ▶️ View Recording
CreateAndRunPythonReactProject ▶️ View Recording
CreateAndRunTypeScriptEmptyAppHostProject ▶️ View Recording
CreateAndRunTypeScriptStarterProject ▶️ View Recording
CreateJavaAppHostWithViteApp ▶️ View Recording
CreateStartAndStopAspireProject ▶️ View Recording
CreateTypeScriptAppHostWithViteApp ▶️ View Recording
DashboardRunWithOtelTracesReturnsNoTraces ▶️ View Recording
DeployK8sBasicApiService ▶️ View Recording
DeployK8sWithGarnet ▶️ View Recording
DeployK8sWithMongoDB ▶️ View Recording
DeployK8sWithMySql ▶️ View Recording
DeployK8sWithPostgres ▶️ View Recording
DeployK8sWithRabbitMQ ▶️ View Recording
DeployK8sWithRedis ▶️ View Recording
DeployK8sWithSqlServer ▶️ View Recording
DeployK8sWithValkey ▶️ View Recording
DeployTypeScriptAppToKubernetes ▶️ View Recording
DescribeCommandResolvesReplicaNames ▶️ View Recording
DescribeCommandShowsRunningResources ▶️ View Recording
DetachFormatJsonProducesValidJson ▶️ View Recording
DoctorCommand_DetectsDeprecatedAgentConfig ▶️ View Recording
DoctorCommand_WithSslCertDir_ShowsTrusted ▶️ View Recording
DoctorCommand_WithoutSslCertDir_ShowsPartiallyTrusted ▶️ View Recording
GlobalMigration_HandlesCommentsAndTrailingCommas ▶️ View Recording
GlobalMigration_HandlesMalformedLegacyJson ▶️ View Recording
GlobalMigration_PreservesAllValueTypes ▶️ View Recording
GlobalMigration_SkipsWhenNewConfigExists ▶️ View Recording
GlobalSettings_MigratedFromLegacyFormat ▶️ View Recording
InitTypeScriptAppHost_AugmentsExistingViteRepoAtRoot ▶️ View Recording
InvalidAppHostPathWithComments_IsHealedOnRun ▶️ View Recording
LegacySettingsMigration_AdjustsRelativeAppHostPath ▶️ View Recording
LogsCommandShowsResourceLogs ▶️ View Recording
PsCommandListsRunningAppHost ▶️ View Recording
PsFormatJsonOutputsOnlyJsonToStdout ▶️ View Recording
PublishWithDockerComposeServiceCallbackSucceeds ▶️ View Recording
RestoreGeneratesSdkFiles ▶️ View Recording
RestoreSupportsConfigOnlyHelperPackageAndCrossPackageTypes ▶️ View Recording
RunFromParentDirectory_UsesExistingConfigNearAppHost ▶️ View Recording
SecretCrudOnDotNetAppHost ▶️ View Recording
SecretCrudOnTypeScriptAppHost ▶️ View Recording
StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels ▶️ View Recording
StartAndWaitForTypeScriptSqlServerAppHostWithNativeAssets ▶️ View Recording
StopAllAppHostsFromAppHostDirectory ▶️ View Recording
StopAllAppHostsFromUnrelatedDirectory ▶️ View Recording
StopNonInteractiveMultipleAppHostsShowsError ▶️ View Recording
StopNonInteractiveSingleAppHost ▶️ View Recording
StopWithNoRunningAppHostExitsSuccessfully ▶️ View Recording
UnAwaitedChainsCompileWithAutoResolvePromises ▶️ View Recording

📹 Recordings uploaded automatically from CI run #24325113651

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add aspire do cleanup command for Azure (and unify cleanup semantics across environments)

4 participants