From 4fa846ad46a2c239895d2df609ed2382490bc11e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 05:29:32 +0000 Subject: [PATCH 1/5] Initial plan From 9955ef0888a3007ff480b0834baf10260caa70fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 05:38:56 +0000 Subject: [PATCH 2/5] Refactor to void deployment state even when resources are already deleted Extract state voiding logic into separate method and ensure it runs even when no resources are found. This allows azd down to properly clean deployment state when resources have been manually deleted. Co-authored-by: vhvb1989 <24213737+vhvb1989@users.noreply.github.com> --- cli/azd/pkg/azapi/standard_deployments.go | 13 +- .../provisioning/bicep/bicep_provider.go | 201 +++++++++++------- 2 files changed, 132 insertions(+), 82 deletions(-) diff --git a/cli/azd/pkg/azapi/standard_deployments.go b/cli/azd/pkg/azapi/standard_deployments.go index 789d983b1e3..4dac2019d4d 100644 --- a/cli/azd/pkg/azapi/standard_deployments.go +++ b/cli/azd/pkg/azapi/standard_deployments.go @@ -456,7 +456,18 @@ func (ds *StandardDeployments) DeleteSubscriptionDeployment( }) } - // Deploy empty template to void provision state and keep deployment history instead of deleting previous deployments + // Void the deployment state + return ds.voidSubscriptionDeploymentState(ctx, subscriptionId, deploymentName, options) +} + +// voidSubscriptionDeploymentState deploys an empty template to void the provision state +// and keep deployment history instead of deleting previous deployments. +func (ds *StandardDeployments) voidSubscriptionDeploymentState( + ctx context.Context, + subscriptionId string, + deploymentName string, + options map[string]any, +) error { // Get deployment metadata deployment, err := ds.GetSubscriptionDeployment(ctx, subscriptionId, deploymentName) if err != nil { diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go index 5d9c01ee0d1..895928323b4 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go @@ -829,98 +829,104 @@ func (p *BicepProvider) Destroy( return nil, fmt.Errorf("mapping resources to resource groups: %w", err) } + // If no resources found, we still need to void the deployment state + // This can happen when resources have been manually deleted if len(groupedResources) == 0 { - return nil, fmt.Errorf("%w, '%s'", infra.ErrDeploymentResourcesNotFound, deploymentToDelete.Name()) - } - - keyVaults, err := p.getKeyVaultsToPurge(ctx, groupedResources) - if err != nil { - return nil, fmt.Errorf("getting key vaults to purge: %w", err) - } - - managedHSMs, err := p.getManagedHSMsToPurge(ctx, groupedResources) - if err != nil { - return nil, fmt.Errorf("getting managed hsms to purge: %w", err) - } - - appConfigs, err := p.getAppConfigsToPurge(ctx, groupedResources) - if err != nil { - return nil, fmt.Errorf("getting app configurations to purge: %w", err) - } + p.console.StopSpinner(ctx, "", input.StepDone) + // Call deployment.Delete to void the state even though there are no resources to delete + if err := p.destroyDeploymentWithoutConfirmation(ctx, deploymentToDelete); err != nil { + return nil, fmt.Errorf("voiding deployment state: %w", err) + } + } else { + keyVaults, err := p.getKeyVaultsToPurge(ctx, groupedResources) + if err != nil { + return nil, fmt.Errorf("getting key vaults to purge: %w", err) + } - apiManagements, err := p.getApiManagementsToPurge(ctx, groupedResources) - if err != nil { - return nil, fmt.Errorf("getting API managements to purge: %w", err) - } + managedHSMs, err := p.getManagedHSMsToPurge(ctx, groupedResources) + if err != nil { + return nil, fmt.Errorf("getting managed hsms to purge: %w", err) + } - cognitiveAccounts, err := p.getCognitiveAccountsToPurge(ctx, groupedResources) - if err != nil { - return nil, fmt.Errorf("getting cognitive accounts to purge: %w", err) - } + appConfigs, err := p.getAppConfigsToPurge(ctx, groupedResources) + if err != nil { + return nil, fmt.Errorf("getting app configurations to purge: %w", err) + } - p.console.StopSpinner(ctx, "", input.StepDone) - if err := p.destroyDeploymentWithConfirmation( - ctx, - options, - deploymentToDelete, - groupedResources, - len(resourcesToDelete), - ); err != nil { - return nil, fmt.Errorf("deleting resource groups: %w", err) - } - - keyVaultsPurge := itemToPurge{ - resourceType: "Key Vault", - count: len(keyVaults), - purge: func(skipPurge bool, self *itemToPurge) error { - return p.purgeKeyVaults(ctx, keyVaults, skipPurge) - }, - } - managedHSMsPurge := itemToPurge{ - resourceType: "Managed HSM", - count: len(managedHSMs), - purge: func(skipPurge bool, self *itemToPurge) error { - return p.purgeManagedHSMs(ctx, managedHSMs, skipPurge) - }, - } - appConfigsPurge := itemToPurge{ - resourceType: "App Configuration", - count: len(appConfigs), - purge: func(skipPurge bool, self *itemToPurge) error { - return p.purgeAppConfigs(ctx, appConfigs, skipPurge) - }, - } - aPIManagement := itemToPurge{ - resourceType: "API Management", - count: len(apiManagements), - purge: func(skipPurge bool, self *itemToPurge) error { - return p.purgeAPIManagement(ctx, apiManagements, skipPurge) - }, - } + apiManagements, err := p.getApiManagementsToPurge(ctx, groupedResources) + if err != nil { + return nil, fmt.Errorf("getting API managements to purge: %w", err) + } - var purgeItem []itemToPurge - for _, item := range []itemToPurge{keyVaultsPurge, managedHSMsPurge, appConfigsPurge, aPIManagement} { - if item.count > 0 { - purgeItem = append(purgeItem, item) + cognitiveAccounts, err := p.getCognitiveAccountsToPurge(ctx, groupedResources) + if err != nil { + return nil, fmt.Errorf("getting cognitive accounts to purge: %w", err) } - } - // cognitive services are grouped by resource group because the name of the resource group is required to purge - groupByKind := cognitiveAccountsByKind(cognitiveAccounts) - for name, cogAccounts := range groupByKind { - addPurgeItem := itemToPurge{ - resourceType: name, - count: len(cogAccounts), + p.console.StopSpinner(ctx, "", input.StepDone) + if err := p.destroyDeploymentWithConfirmation( + ctx, + options, + deploymentToDelete, + groupedResources, + len(resourcesToDelete), + ); err != nil { + return nil, fmt.Errorf("deleting resource groups: %w", err) + } + + keyVaultsPurge := itemToPurge{ + resourceType: "Key Vault", + count: len(keyVaults), purge: func(skipPurge bool, self *itemToPurge) error { - return p.purgeCognitiveAccounts(ctx, self.cognitiveAccounts, skipPurge) + return p.purgeKeyVaults(ctx, keyVaults, skipPurge) }, - cognitiveAccounts: groupByKind[name], } - purgeItem = append(purgeItem, addPurgeItem) - } + managedHSMsPurge := itemToPurge{ + resourceType: "Managed HSM", + count: len(managedHSMs), + purge: func(skipPurge bool, self *itemToPurge) error { + return p.purgeManagedHSMs(ctx, managedHSMs, skipPurge) + }, + } + appConfigsPurge := itemToPurge{ + resourceType: "App Configuration", + count: len(appConfigs), + purge: func(skipPurge bool, self *itemToPurge) error { + return p.purgeAppConfigs(ctx, appConfigs, skipPurge) + }, + } + aPIManagement := itemToPurge{ + resourceType: "API Management", + count: len(apiManagements), + purge: func(skipPurge bool, self *itemToPurge) error { + return p.purgeAPIManagement(ctx, apiManagements, skipPurge) + }, + } + + var purgeItem []itemToPurge + for _, item := range []itemToPurge{keyVaultsPurge, managedHSMsPurge, appConfigsPurge, aPIManagement} { + if item.count > 0 { + purgeItem = append(purgeItem, item) + } + } + + // cognitive services are grouped by resource group because the name of the resource group is required to purge + groupByKind := cognitiveAccountsByKind(cognitiveAccounts) + for name, cogAccounts := range groupByKind { + addPurgeItem := itemToPurge{ + resourceType: name, + count: len(cogAccounts), + purge: func(skipPurge bool, self *itemToPurge) error { + return p.purgeCognitiveAccounts(ctx, self.cognitiveAccounts, skipPurge) + }, + cognitiveAccounts: groupByKind[name], + } + purgeItem = append(purgeItem, addPurgeItem) + } - if err := p.purgeItems(ctx, purgeItem, options); err != nil { - return nil, fmt.Errorf("purging resources: %w", err) + if err := p.purgeItems(ctx, purgeItem, options); err != nil { + return nil, fmt.Errorf("purging resources: %w", err) + } } destroyResult := &provisioning.DestroyResult{ @@ -1121,6 +1127,39 @@ func (p *BicepProvider) destroyDeploymentWithConfirmation( return nil } +// destroyDeploymentWithoutConfirmation deletes the deployment without prompting for confirmation. +// This is used when there are no resources to delete but we still need to void the deployment state. +func (p *BicepProvider) destroyDeploymentWithoutConfirmation( + ctx context.Context, + deployment infra.Deployment, +) error { + err := async.RunWithProgressE(func(progressMessage azapi.DeleteDeploymentProgress) { + switch progressMessage.State { + case azapi.DeleteResourceStateInProgress: + p.console.ShowSpinner(ctx, progressMessage.Message, input.Step) + case azapi.DeleteResourceStateSucceeded: + p.console.StopSpinner(ctx, progressMessage.Message, input.StepDone) + case azapi.DeleteResourceStateFailed: + p.console.StopSpinner(ctx, progressMessage.Message, input.StepFailed) + } + }, func(progress *async.Progress[azapi.DeleteDeploymentProgress]) error { + optionsMap, err := convert.ToMap(p.options) + if err != nil { + return err + } + + return deployment.Delete(ctx, optionsMap, progress) + }) + + if err != nil { + return err + } + + p.console.Message(ctx, "") + + return nil +} + func itemsCountAsText(items []itemToPurge) string { count := len(items) if count < 1 { From 789a901f05b996119c02f2126b36506afea689cc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 05:48:18 +0000 Subject: [PATCH 3/5] Final implementation complete - ready for review Co-authored-by: vhvb1989 <24213737+vhvb1989@users.noreply.github.com> --- cli/azd/go.mod | 1 - cli/azd/go.sum | 2 -- 2 files changed, 3 deletions(-) diff --git a/cli/azd/go.mod b/cli/azd/go.mod index e546991bbfa..f51c6a9552f 100644 --- a/cli/azd/go.mod +++ b/cli/azd/go.mod @@ -52,7 +52,6 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 - github.com/magefile/mage v1.15.0 github.com/mark3labs/mcp-go v0.41.1 github.com/mattn/go-colorable v0.1.14 github.com/mattn/go-isatty v0.0.20 diff --git a/cli/azd/go.sum b/cli/azd/go.sum index 9a9a668bb40..b245f675535 100644 --- a/cli/azd/go.sum +++ b/cli/azd/go.sum @@ -284,8 +284,6 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kUL github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= -github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA= From dfdaacfc9d63409a4986bcfcff269520f56ab396 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 05:49:24 +0000 Subject: [PATCH 4/5] Add detailed comments explaining state voiding behavior Co-authored-by: vhvb1989 <24213737+vhvb1989@users.noreply.github.com> --- cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go index 895928323b4..5ed22535902 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go @@ -829,8 +829,10 @@ func (p *BicepProvider) Destroy( return nil, fmt.Errorf("mapping resources to resource groups: %w", err) } - // If no resources found, we still need to void the deployment state - // This can happen when resources have been manually deleted + // If no resources found, we still need to void the deployment state. + // This can happen when resources have been manually deleted before running azd down. + // Voiding the state ensures that subsequent azd provision commands work correctly + // by creating a new empty deployment that becomes the last successful deployment. if len(groupedResources) == 0 { p.console.StopSpinner(ctx, "", input.StepDone) // Call deployment.Delete to void the state even though there are no resources to delete From 028bfc01b3235602fc2a919c7e04792b4d0f5f52 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Nov 2025 17:54:01 +0000 Subject: [PATCH 5/5] Refactor: Extract promptDeletion and single destroyDeployment to remove duplication Co-authored-by: JeffreyCA <9157833+JeffreyCA@users.noreply.github.com> --- .../provisioning/bicep/bicep_provider.go | 85 +++++++------------ 1 file changed, 31 insertions(+), 54 deletions(-) diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go index 5ed22535902..dc7d7687d6a 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go @@ -836,7 +836,7 @@ func (p *BicepProvider) Destroy( if len(groupedResources) == 0 { p.console.StopSpinner(ctx, "", input.StepDone) // Call deployment.Delete to void the state even though there are no resources to delete - if err := p.destroyDeploymentWithoutConfirmation(ctx, deploymentToDelete); err != nil { + if err := p.destroyDeployment(ctx, deploymentToDelete); err != nil { return nil, fmt.Errorf("voiding deployment state: %w", err) } } else { @@ -866,13 +866,15 @@ func (p *BicepProvider) Destroy( } p.console.StopSpinner(ctx, "", input.StepDone) - if err := p.destroyDeploymentWithConfirmation( - ctx, - options, - deploymentToDelete, - groupedResources, - len(resourcesToDelete), - ); err != nil { + + // Prompt for confirmation before deleting resources + if err := p.promptDeletion(ctx, options, groupedResources, len(resourcesToDelete)); err != nil { + return nil, err + } + + p.console.Message(ctx, output.WithGrayFormat("Deleting your resources can take some time.\n")) + + if err := p.destroyDeployment(ctx, deploymentToDelete); err != nil { return nil, fmt.Errorf("deleting resource groups: %w", err) } @@ -1070,68 +1072,43 @@ func (p *BicepProvider) generateResourcesToDelete(groupedResources map[string][] return append(lines, "\n") } -// Deletes the azure resources within the deployment -func (p *BicepProvider) destroyDeploymentWithConfirmation( +// promptDeletion prompts the user for confirmation before deleting resources. +// Returns nil if the user confirms, or an error if they deny or an error occurs. +func (p *BicepProvider) promptDeletion( ctx context.Context, options provisioning.DestroyOptions, - deployment infra.Deployment, groupedResources map[string][]*azapi.Resource, resourceCount int, ) error { - if !options.Force() { - p.console.MessageUxItem(ctx, &ux.MultilineMessage{ - Lines: p.generateResourcesToDelete(groupedResources)}, - ) - confirmDestroy, err := p.console.Confirm(ctx, input.ConsoleOptions{ - Message: fmt.Sprintf( - "Total resources to %s: %d, are you sure you want to continue?", - output.WithErrorFormat("delete"), - resourceCount, - ), - DefaultValue: false, - }) - - if err != nil { - return fmt.Errorf("prompting for delete confirmation: %w", err) - } - - if !confirmDestroy { - return errors.New("user denied delete confirmation") - } + if options.Force() { + return nil } - p.console.Message(ctx, output.WithGrayFormat("Deleting your resources can take some time.\n")) - - err := async.RunWithProgressE(func(progressMessage azapi.DeleteDeploymentProgress) { - switch progressMessage.State { - case azapi.DeleteResourceStateInProgress: - p.console.ShowSpinner(ctx, progressMessage.Message, input.Step) - case azapi.DeleteResourceStateSucceeded: - p.console.StopSpinner(ctx, progressMessage.Message, input.StepDone) - case azapi.DeleteResourceStateFailed: - p.console.StopSpinner(ctx, progressMessage.Message, input.StepFailed) - } - }, func(progress *async.Progress[azapi.DeleteDeploymentProgress]) error { - optionsMap, err := convert.ToMap(p.options) - if err != nil { - return err - } - - return deployment.Delete(ctx, optionsMap, progress) + p.console.MessageUxItem(ctx, &ux.MultilineMessage{ + Lines: p.generateResourcesToDelete(groupedResources)}, + ) + confirmDestroy, err := p.console.Confirm(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf( + "Total resources to %s: %d, are you sure you want to continue?", + output.WithErrorFormat("delete"), + resourceCount, + ), + DefaultValue: false, }) if err != nil { - return err + return fmt.Errorf("prompting for delete confirmation: %w", err) } - p.console.Message(ctx, "") + if !confirmDestroy { + return errors.New("user denied delete confirmation") + } return nil } -// destroyDeploymentWithoutConfirmation deletes the deployment without prompting for confirmation. -// This is used when there are no resources to delete but we still need to void the deployment state. -func (p *BicepProvider) destroyDeploymentWithoutConfirmation( +// destroyDeployment deletes the azure resources within the deployment and voids the deployment state. +func (p *BicepProvider) destroyDeployment( ctx context.Context, deployment infra.Deployment, ) error {