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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions cli/azd/cmd/down.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ func (a *downAction) Run(ctx context.Context) (*actions.ActionResult, error) {
}
slices.Reverse(layers)

skippedDeletion := false
for _, layer := range layers {
if downLayer != "" || len(layers) > 1 {
a.console.EnsureBlankLine(ctx)
Expand All @@ -146,19 +147,27 @@ func (a *downAction) Run(ctx context.Context) (*actions.ActionResult, error) {
}

destroyOptions := provisioning.NewDestroyOptions(a.flags.forceDelete, a.flags.purgeDelete)
_, err := a.provisionManager.Destroy(ctx, destroyOptions)
destroyResult, err := a.provisionManager.Destroy(ctx, destroyOptions)
if errors.Is(err, inf.ErrDeploymentsNotFound) || errors.Is(err, inf.ErrDeploymentResourcesNotFound) {
a.console.MessageUxItem(ctx, &ux.DoneMessage{Message: "No Azure resources were found."})
} else if err != nil {
return nil, fmt.Errorf("deleting infrastructure: %w", err)
} else if destroyResult != nil && destroyResult.SkippedDeletion {
skippedDeletion = true
Comment thread
hemarina marked this conversation as resolved.
}
}

// Invalidate cache after successful down so azd show will refresh
// Invalidate cache after down so azd show will refresh. Always invalidate: with multiple layers, some
// may have deleted resources even if another layer was only previewed.
if err := a.envManager.InvalidateEnvCache(ctx, a.env.Name()); err != nil {
Comment thread
hemarina marked this conversation as resolved.
log.Printf("warning: failed to invalidate state cache: %v", err)
}

// When any layer was skipped (e.g. --no-prompt in CI without --force), don't report a full teardown.
if skippedDeletion {
return &actions.ActionResult{}, nil
}

return &actions.ActionResult{
Message: &actions.ResultMessage{
Header: fmt.Sprintf("Your application was removed from Azure in %s.", ux.DurationAsText(since(startTime))),
Expand All @@ -172,6 +181,9 @@ func getCmdDownHelpDescription(*cobra.Command) string {
" files on your local machine.", output.WithHighLightFormat("azd down")), []string{
"When <layer> is specified, only deletes resources for the given layer." +
" When omitted, deletes resources for all layers defined in the project.",
"For Terraform projects, running non-interactively (in a CI/CD pipeline or with --no-prompt)" +
" without --force previews the resources that would be deleted and exits without deleting" +
" anything. Re-run with --force to delete resources without confirmation.",
})
}

Expand Down
118 changes: 118 additions & 0 deletions cli/azd/cmd/down_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,23 @@
package cmd

import (
"context"
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"

"github.com/azure/azure-dev/cli/azd/internal"
"github.com/azure/azure-dev/cli/azd/pkg/alpha"
"github.com/azure/azure-dev/cli/azd/pkg/cloud"
"github.com/azure/azure-dev/cli/azd/pkg/config"
"github.com/azure/azure-dev/cli/azd/pkg/environment"
"github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning"
"github.com/azure/azure-dev/cli/azd/pkg/ioc"
"github.com/azure/azure-dev/cli/azd/pkg/project"
"github.com/azure/azure-dev/cli/azd/test/mocks/mockenv"
"github.com/azure/azure-dev/cli/azd/test/mocks/mockinput"
)

Expand All @@ -37,3 +47,111 @@ func Test_NewDownFlags(t *testing.T) {
flags := newDownFlags(cmd, global)
require.NotNil(t, flags)
}

// mockDownProvider is a provisioning.Provider with a configurable Destroy result, reusing
// mockRefreshProvider for the remaining interface methods.
type mockDownProvider struct {
*mockRefreshProvider
destroyResult *provisioning.DestroyResult
destroyErr error
}

func (p *mockDownProvider) Destroy(
_ context.Context, _ provisioning.DestroyOptions,
) (*provisioning.DestroyResult, error) {
return p.destroyResult, p.destroyErr
}

// newTestDownAction wires a downAction against a real provisioning.Manager backed by the given
// mock provider, mirroring newTestEnvRefreshAction.
func newTestDownAction(
t *testing.T,
provider provisioning.Provider,
) (*downAction, *mockinput.MockConsole, *mockenv.MockEnvManager) {
t.Helper()

container := ioc.NewNestedContainer(nil)
ioc.RegisterNamedInstance(container, string(provisioning.Test), provider)

env := environment.New("test-env")
env.SetSubscriptionId("00000000-0000-0000-0000-000000000000")
env.SetLocation("eastus2")

console := mockinput.NewMockConsole()

envManager := &mockenv.MockEnvManager{}
envManager.On("Save", mock.Anything, mock.Anything).Return(nil)
envManager.On("InvalidateEnvCache", mock.Anything, mock.Anything).Return(nil)

provisionManager := provisioning.NewManager(
container,
func() (provisioning.ProviderKind, error) { return provisioning.Test, nil },
envManager,
env,
console,
alpha.NewFeaturesManagerWithConfig(config.NewEmptyConfig()),
nil, // fileShareService
cloud.AzurePublic(),
)

projectConfig := &project.ProjectConfig{
Name: "test-project",
Path: t.TempDir(),
Infra: provisioning.Options{
Provider: provisioning.Test,
Path: "infra",
Module: "main",
// Set Layers so ProjectInfrastructure resolves without requiring infra files on disk.
Layers: []provisioning.Options{
{Name: "infra", Provider: provisioning.Test, Path: "infra", Module: "main"},
},
},
}

action := &downAction{
flags: &downFlags{global: &internal.GlobalCommandOptions{}},
provisionManager: provisionManager,
env: env,
envManager: envManager,
console: console,
projectConfig: projectConfig,
importManager: project.NewImportManager(nil),
alphaFeatureManager: alpha.NewFeaturesManagerWithConfig(config.NewEmptyConfig()),
}

return action, console, envManager
}

// Test_DownAction_Run_SkippedDeletion verifies that when the provider reports SkippedDeletion (e.g.
// a --no-prompt CI preview), azd down does not emit the full-teardown success header. See #4317.
func Test_DownAction_Run_SkippedDeletion(t *testing.T) {
provider := &mockDownProvider{
mockRefreshProvider: &mockRefreshProvider{},
destroyResult: &provisioning.DestroyResult{SkippedDeletion: true},
}
action, _, envManager := newTestDownAction(t, provider)

result, err := action.Run(t.Context())

require.NoError(t, err)
require.NotNil(t, result)
require.Nil(t, result.Message, "no success message expected when deletion was skipped")
// Cache is still invalidated so azd show refreshes.
envManager.AssertCalled(t, "InvalidateEnvCache", mock.Anything, mock.Anything)
}

// Test_DownAction_Run_Deleted verifies that a normal destroy still reports the teardown success header.
func Test_DownAction_Run_Deleted(t *testing.T) {
provider := &mockDownProvider{
mockRefreshProvider: &mockRefreshProvider{},
destroyResult: &provisioning.DestroyResult{},
}
action, _, _ := newTestDownAction(t, provider)

result, err := action.Run(t.Context())

require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Message)
require.Contains(t, result.Message.Header, "Your application was removed")
}
1 change: 1 addition & 0 deletions cli/azd/cmd/testdata/TestUsage-azd-down.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Delete Azure resources for an application. Running azd down will not delete application files on your local machine.

When <layer> is specified, only deletes resources for the given layer. When omitted, deletes resources for all layers defined in the project.
For Terraform projects, running non-interactively (in a CI/CD pipeline or with --no-prompt) without --force previews the resources that would be deleted and exits without deleting anything. Re-run with --force to delete resources without confirmation.

Usage
azd down [<layer>] [flags]
Expand Down
3 changes: 3 additions & 0 deletions cli/azd/pkg/infra/provisioning/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,9 @@ type DeployPreviewResult struct {
type DestroyResult struct {
// InvalidatedEnvKeys is a list of keys that should be removed from the environment after the destroy is complete.
InvalidatedEnvKeys []string
// SkippedDeletion is true when the provider intentionally did not delete any resources (for example, when
// running with --no-prompt in a CI/CD environment without --force, where only a destroy preview is shown).
SkippedDeletion bool
}

type StateResult struct {
Expand Down
52 changes: 49 additions & 3 deletions cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ import (
"strings"

"github.com/azure/azure-dev/cli/azd/internal"
"github.com/azure/azure-dev/cli/azd/internal/tracing/resource"
"github.com/azure/azure-dev/cli/azd/pkg/azsdk"
"github.com/azure/azure-dev/cli/azd/pkg/environment"
"github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning"
"github.com/azure/azure-dev/cli/azd/pkg/input"
"github.com/azure/azure-dev/cli/azd/pkg/osutil"
"github.com/azure/azure-dev/cli/azd/pkg/output/ux"
"github.com/azure/azure-dev/cli/azd/pkg/prompt"
"github.com/azure/azure-dev/cli/azd/pkg/tools"
"github.com/azure/azure-dev/cli/azd/pkg/tools/terraform"
Expand Down Expand Up @@ -150,7 +152,8 @@ func (t *TerraformProvider) plan(ctx context.Context) (*provisioning.Deployment,

modulePath := t.modulePath()

initRes, err := t.init(ctx, isRemoteBackendConfig)
// -upgrade lets provisioning pick up newer providers/modules before planning.
initRes, err := t.init(ctx, isRemoteBackendConfig, "-upgrade")
if err != nil {
return nil, nil, fmt.Errorf("terraform init failed: %s , err: %w", initRes, err)
}
Expand Down Expand Up @@ -259,6 +262,44 @@ func (t *TerraformProvider) Destroy(

modulePath := t.modulePath()

// terraform destroy asks for confirmation interactively unless --force (-auto-approve) is passed.
// When running non-interactively — a CI/CD pipeline, an AI agent, or an explicit --no-prompt — azd
// cannot answer that prompt, so terraform would block on stdin and hang. Detect these automated
// contexts up front.
// Initialize the backend non-interactively (-input=false) for any automated destroy so that both the
// preview and a --force destroy work on a fresh agent; otherwise `terraform output`/`terraform destroy`
// fail with "backend initialization required" (a symptom reported in #4317). init is idempotent, so
// re-running it when the backend is already initialized is a safe no-op. Note: no -upgrade here — a
// teardown must not select newer providers/modules or rewrite `.terraform.lock.hcl`.
automated := resource.IsRunningOnCI() || t.console.IsNoPromptMode()
Comment thread
hemarina marked this conversation as resolved.
if automated {
if initRes, err := t.init(ctx, isRemoteBackendConfig, "-input=false"); err != nil {
Comment thread
hemarina marked this conversation as resolved.
return nil, fmt.Errorf("terraform init failed: %s, err: %w", initRes, err)
}
}

// In an automated context without --force, azd cannot answer terraform's confirmation, so instead of
// hanging, preview the destroy plan (read-only) and stop without deleting — as if the confirmation were
// answered "no". Use --force to delete non-interactively. See issue #4317.
if automated && !options.Force() {
Comment thread
hemarina marked this conversation as resolved.
t.console.Message(ctx, "Previewing resources to delete...")
// terraform doesn't use `t.console`; stop any spinner before streaming its output.
t.console.StopSpinner(ctx, "", input.Step)

previewArgs := t.createPlanArgs(isRemoteBackendConfig)
if previewRes, err := t.cli.PlanDestroy(ctx, modulePath, previewArgs...); err != nil {
return nil, fmt.Errorf("previewing destroy failed: %s, err: %w", previewRes, err)
}

t.console.MessageUxItem(ctx, &ux.WarningMessage{
Description: "No resources were deleted for this Terraform configuration because azd is running " +
"non-interactively.",
Hints: []string{"Re-run with --force to delete resources without confirmation."},
})

return &provisioning.DestroyResult{SkippedDeletion: true}, nil
}

//load the deployment result
outputs, err := t.createOutputParameters(ctx, modulePath, isRemoteBackendConfig)
if err != nil {
Expand Down Expand Up @@ -366,8 +407,11 @@ func (t *TerraformProvider) ensureParametersFile(ctx context.Context) error {
}

// initialize template terraform provider through terraform init
func (t *TerraformProvider) init(ctx context.Context, isRemoteBackendConfig bool) (string, error) {

func (t *TerraformProvider) init(
ctx context.Context,
isRemoteBackendConfig bool,
additionalArgs ...string,
) (string, error) {
modulePath := t.modulePath()
cmd := []string{}

Expand All @@ -381,6 +425,8 @@ func (t *TerraformProvider) init(ctx context.Context, isRemoteBackendConfig bool
cmd = append(cmd, fmt.Sprintf("--backend-config=%s", t.backendConfigFilePath()))
}

cmd = append(cmd, additionalArgs...)

runResult, err := t.cli.Init(ctx, modulePath, cmd...)
if err != nil {
return runResult, err
Expand Down
Loading
Loading