From 83b5a41ab0302871a2ae6f119fff208ec3fb65ed Mon Sep 17 00:00:00 2001 From: hemarina Date: Tue, 14 Jul 2026 16:51:51 -0700 Subject: [PATCH 1/7] fix 4317 --- cli/azd/cmd/down.go | 11 ++- cli/azd/pkg/infra/provisioning/provider.go | 3 + .../terraform/terraform_provider.go | 28 ++++++ .../terraform/terraform_provider_test.go | 88 +++++++++++++++++++ cli/azd/pkg/tools/terraform/terraform.go | 23 +++++ 5 files changed, 152 insertions(+), 1 deletion(-) diff --git a/cli/azd/cmd/down.go b/cli/azd/cmd/down.go index 27c854fca80..303802e6f2b 100644 --- a/cli/azd/cmd/down.go +++ b/cli/azd/cmd/down.go @@ -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) @@ -146,14 +147,22 @@ 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 } } + // When deletion was skipped (e.g. --no-prompt in CI without --force), nothing was deleted, so don't + // invalidate the state cache or report a successful teardown. + if skippedDeletion { + return &actions.ActionResult{}, nil + } + // Invalidate cache after successful down so azd show will refresh if err := a.envManager.InvalidateEnvCache(ctx, a.env.Name()); err != nil { log.Printf("warning: failed to invalidate state cache: %v", err) diff --git a/cli/azd/pkg/infra/provisioning/provider.go b/cli/azd/pkg/infra/provisioning/provider.go index a27d5d52923..7e66b536c0a 100644 --- a/cli/azd/pkg/infra/provisioning/provider.go +++ b/cli/azd/pkg/infra/provisioning/provider.go @@ -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 { diff --git a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go index 3c86efc0055..bfaf2300db1 100644 --- a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go +++ b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go @@ -15,10 +15,12 @@ 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/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" @@ -253,6 +255,32 @@ func (t *TerraformProvider) Destroy( modulePath := t.modulePath() + // terraform destroy asks for confirmation interactively unless --force is passed. + // In a CI/CD pipeline with --no-prompt, azd cannot answer that prompt, so terraform would block + // reading stdin and hang. In that case, preview the destroy plan (read-only) and stop without + // deleting, as if the confirmation were answered "no". Use --force to delete in CI. See issue #4317. + if resource.IsRunningOnCI() && !options.Force() && t.console.IsNoPromptMode() { + 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) + + if initRes, err := t.init(ctx, isRemoteBackendConfig); err != nil { + return nil, fmt.Errorf("terraform init failed: %s, err: %w", initRes, err) + } + + 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 because --no-prompt was set in a CI/CD environment.", + 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 { diff --git a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go index f62a474174b..776a4148f96 100644 --- a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go +++ b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go @@ -81,6 +81,94 @@ func TestTerraformDestroy(t *testing.T) { require.Contains(t, destroyResult.InvalidatedEnvKeys, "RG_NAME") } +// TestTerraformDestroyCIPreviewsWithoutDeleting verifies that `azd down --no-prompt` in a CI/CD +// environment (without --force) previews the destroy plan and does NOT delete anything. terraform's +// interactive destroy confirmation would otherwise block on stdin and hang. See issue #4317. +func TestTerraformDestroyCIPreviewsWithoutDeleting(t *testing.T) { + skipIfTerraformNotInstalled(t) + t.Setenv("TF_BUILD", "True") // simulate an Azure Pipelines (CI) run + + mockContext := mocks.NewMockContext(t.Context()) + prepareGenericMocks(mockContext.CommandRunner) + preparePlanningMocks(mockContext.CommandRunner) + + infraProvider := createTerraformProvider(t, mockContext) + mockContext.Console.SetNoPromptMode(true) + + destroyOptions := provisioning.NewDestroyOptions(false, false) + destroyResult, err := infraProvider.Destroy(*mockContext.Context, destroyOptions) + + require.NoError(t, err) + require.NotNil(t, destroyResult) + require.True(t, destroyResult.SkippedDeletion) + require.Empty(t, destroyResult.InvalidatedEnvKeys) +} + +// TestTerraformDestroyCIWithForceDeletes verifies that --force bypasses the CI preview so that +// `azd down --no-prompt --force` still tears resources down (terraform gets -auto-approve). +func TestTerraformDestroyCIWithForceDeletes(t *testing.T) { + skipIfTerraformNotInstalled(t) + t.Setenv("TF_BUILD", "True") + + mockContext := mocks.NewMockContext(t.Context()) + prepareGenericMocks(mockContext.CommandRunner) + preparePlanningMocks(mockContext.CommandRunner) + prepareDestroyMocks(mockContext.CommandRunner) + + infraProvider := createTerraformProvider(t, mockContext) + mockContext.Console.SetNoPromptMode(true) + + destroyOptions := provisioning.NewDestroyOptions(true, false) + destroyResult, err := infraProvider.Destroy(*mockContext.Context, destroyOptions) + + require.NoError(t, err) + require.NotNil(t, destroyResult) + require.False(t, destroyResult.SkippedDeletion) + require.Contains(t, destroyResult.InvalidatedEnvKeys, "AZURE_LOCATION") + require.Contains(t, destroyResult.InvalidatedEnvKeys, "RG_NAME") +} + +// TestTerraformDestroyNoPromptNotCIDeletes verifies that --no-prompt alone (outside CI) does not trigger +// the preview path — behavior is unchanged and terraform destroy is invoked. +func TestTerraformDestroyNoPromptNotCIDeletes(t *testing.T) { + skipIfTerraformNotInstalled(t) + clearCIEnv(t) + + mockContext := mocks.NewMockContext(t.Context()) + prepareGenericMocks(mockContext.CommandRunner) + preparePlanningMocks(mockContext.CommandRunner) + prepareDestroyMocks(mockContext.CommandRunner) + + infraProvider := createTerraformProvider(t, mockContext) + mockContext.Console.SetNoPromptMode(true) + + destroyOptions := provisioning.NewDestroyOptions(false, false) + destroyResult, err := infraProvider.Destroy(*mockContext.Context, destroyOptions) + + require.NoError(t, err) + require.NotNil(t, destroyResult) + require.False(t, destroyResult.SkippedDeletion) + require.Contains(t, destroyResult.InvalidatedEnvKeys, "AZURE_LOCATION") +} + +// clearCIEnv unsets CI-related environment variables so resource.IsRunningOnCI() returns false during the +// test. Some vars are existence-based, so they must be unset rather than emptied. +func clearCIEnv(t *testing.T) { + t.Helper() + ciVars := []string{ + "CI", "BUILD_ID", "GITHUB_ACTIONS", "TF_BUILD", + "CODEBUILD_BUILD_ID", "JENKINS_URL", "TEAMCITY_VERSION", + "APPVEYOR", "TRAVIS", "CIRCLECI", "GITLAB_CI", + "JB_SPACE_API_URL", "bamboo.buildKey", "BITBUCKET_BUILD_NUMBER", + } + for _, key := range ciVars { + if val, ok := os.LookupEnv(key); ok { + os.Unsetenv(key) + t.Cleanup(func() { os.Setenv(key, val) }) + } + } +} + func TestTerraformState(t *testing.T) { skipIfTerraformNotInstalled(t) mockContext := mocks.NewMockContext(t.Context()) diff --git a/cli/azd/pkg/tools/terraform/terraform.go b/cli/azd/pkg/tools/terraform/terraform.go index c8b7278883f..5c7cd347d04 100644 --- a/cli/azd/pkg/tools/terraform/terraform.go +++ b/cli/azd/pkg/tools/terraform/terraform.go @@ -234,3 +234,26 @@ func (cli *Cli) Destroy(ctx context.Context, modulePath string, additionalArgs . } return cmdRes.Stdout, nil } + +// PlanDestroy runs `terraform plan -destroy` to preview the resources that would be destroyed, without +// deleting anything. Output is streamed to the console. -input=false ensures terraform never blocks on +// stdin, which makes it safe to run in non-interactive/CI environments. +func (cli *Cli) PlanDestroy(ctx context.Context, modulePath string, additionalArgs ...string) (string, error) { + args := []string{ + fmt.Sprintf("-chdir=%s", modulePath), + "plan", + "-destroy", + "-input=false", + } + + args = append(args, additionalArgs...) + cmdRes, err := cli.runInteractive(ctx, args...) + if err != nil { + return "", fmt.Errorf( + "failed running terraform plan -destroy: %s (%w)", + cmdRes.Stderr, + err, + ) + } + return cmdRes.Stdout, nil +} From 5e884075cc7e40ade9e01fa901b4b434a9017a97 Mon Sep 17 00:00:00 2001 From: hemarina Date: Wed, 15 Jul 2026 15:17:15 -0700 Subject: [PATCH 2/7] address feedback --- cli/azd/cmd/down.go | 14 ++++---- .../terraform/terraform_provider.go | 34 +++++++++++++------ .../terraform/terraform_provider_test.go | 12 +++++++ cli/azd/pkg/tools/terraform/terraform_test.go | 9 +++++ 4 files changed, 51 insertions(+), 18 deletions(-) diff --git a/cli/azd/cmd/down.go b/cli/azd/cmd/down.go index 303802e6f2b..57eab871912 100644 --- a/cli/azd/cmd/down.go +++ b/cli/azd/cmd/down.go @@ -157,17 +157,17 @@ func (a *downAction) Run(ctx context.Context) (*actions.ActionResult, error) { } } - // When deletion was skipped (e.g. --no-prompt in CI without --force), nothing was deleted, so don't - // invalidate the state cache or report a successful teardown. - if skippedDeletion { - return &actions.ActionResult{}, nil - } - - // 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 { 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))), diff --git a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go index bfaf2300db1..7172a5ffca4 100644 --- a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go +++ b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go @@ -255,19 +255,26 @@ func (t *TerraformProvider) Destroy( modulePath := t.modulePath() - // terraform destroy asks for confirmation interactively unless --force is passed. - // In a CI/CD pipeline with --no-prompt, azd cannot answer that prompt, so terraform would block - // reading stdin and hang. In that case, preview the destroy plan (read-only) and stop without - // deleting, as if the confirmation were answered "no". Use --force to delete in CI. See issue #4317. - if resource.IsRunningOnCI() && !options.Force() && t.console.IsNoPromptMode() { + // In a CI/CD pipeline with --no-prompt, azd cannot answer terraform's interactive prompts. Initialize + // the backend non-interactively (-input=false) up front so that both the destroy 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). This only affects the automated CI path. + automatedCI := resource.IsRunningOnCI() && t.console.IsNoPromptMode() + if automatedCI { + if initRes, err := t.init(ctx, isRemoteBackendConfig, "-input=false"); err != nil { + return nil, fmt.Errorf("terraform init failed: %s, err: %w", initRes, err) + } + } + + // terraform destroy asks for confirmation interactively unless --force is passed. In a CI/CD pipeline + // with --no-prompt, azd cannot answer that prompt, so terraform would block on stdin and hang. Instead, + // preview the destroy plan (read-only) and stop without deleting, as if the confirmation were answered + // "no". Use --force to delete in CI. See issue #4317. + if automatedCI && !options.Force() { 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) - if initRes, err := t.init(ctx, isRemoteBackendConfig); err != nil { - return nil, fmt.Errorf("terraform init failed: %s, err: %w", initRes, err) - } - 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) @@ -388,8 +395,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{} @@ -403,6 +413,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 diff --git a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go index 776a4148f96..70e81a85f1d 100644 --- a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go +++ b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go @@ -115,6 +115,17 @@ func TestTerraformDestroyCIWithForceDeletes(t *testing.T) { preparePlanningMocks(mockContext.CommandRunner) prepareDestroyMocks(mockContext.CommandRunner) + // Assert terraform init runs non-interactively for the --force CI path so that a fresh agent does not + // fail with "backend initialization required" before reaching destroy (#4317). + initCalled := false + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return args.Cmd == "terraform" && strings.Contains(command, "init") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + initCalled = true + require.Contains(t, args.Args, "-input=false") + return exec.RunResult{Stdout: "Terraform has been successfully initialized!"}, nil + }) + infraProvider := createTerraformProvider(t, mockContext) mockContext.Console.SetNoPromptMode(true) @@ -124,6 +135,7 @@ func TestTerraformDestroyCIWithForceDeletes(t *testing.T) { require.NoError(t, err) require.NotNil(t, destroyResult) require.False(t, destroyResult.SkippedDeletion) + require.True(t, initCalled) require.Contains(t, destroyResult.InvalidatedEnvKeys, "AZURE_LOCATION") require.Contains(t, destroyResult.InvalidatedEnvKeys, "RG_NAME") } diff --git a/cli/azd/pkg/tools/terraform/terraform_test.go b/cli/azd/pkg/tools/terraform/terraform_test.go index d7a5b708eb8..3b95578738d 100644 --- a/cli/azd/pkg/tools/terraform/terraform_test.go +++ b/cli/azd/pkg/tools/terraform/terraform_test.go @@ -344,6 +344,15 @@ func getCommandTestCases() []commandTestCase { interactive: true, errContains: "failed running terraform destroy", }, + { + name: "PlanDestroy", + invoke: func(cli *Cli, ctx context.Context) (string, error) { + return cli.PlanDestroy(ctx, "/module") + }, + expectedArgs: []string{"-chdir=/module", "plan", "-destroy", "-input=false"}, + interactive: true, + errContains: "failed running terraform plan -destroy", + }, } } From 5370e47babe66799d452d95c4b559295e8638dd7 Mon Sep 17 00:00:00 2001 From: hemarina Date: Wed, 15 Jul 2026 15:25:09 -0700 Subject: [PATCH 3/7] fix test coverage pipeline --- cli/azd/pkg/tools/terraform/terraform_test.go | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/cli/azd/pkg/tools/terraform/terraform_test.go b/cli/azd/pkg/tools/terraform/terraform_test.go index 3b95578738d..0a9905a76b1 100644 --- a/cli/azd/pkg/tools/terraform/terraform_test.go +++ b/cli/azd/pkg/tools/terraform/terraform_test.go @@ -524,6 +524,26 @@ func Test_Commands_AdditionalArgs(t *testing.T) { "-chdir=/module", "destroy", "-auto-approve", }, capturedArgs.Args) }) + + t.Run("PlanDestroy_WithVarFile", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + var capturedArgs exec.RunArgs + + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return args.Cmd == "terraform" + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + capturedArgs = args + return exec.NewRunResult(0, "", ""), nil + }) + + cli := NewCli(mockContext.CommandRunner) + _, err := cli.PlanDestroy(*mockContext.Context, "/module", "-var-file=/params.tfvars.json") + + require.NoError(t, err) + require.Equal(t, []string{ + "-chdir=/module", "plan", "-destroy", "-input=false", "-var-file=/params.tfvars.json", + }, capturedArgs.Args) + }) } func Test_Commands_EnvPropagation(t *testing.T) { From 6f950bb0105695b3b5f5e120732d62f713e0ce5d Mon Sep 17 00:00:00 2001 From: hemarina Date: Thu, 16 Jul 2026 13:51:24 -0700 Subject: [PATCH 4/7] add tests --- cli/azd/cmd/down_test.go | 118 ++++++++++++++++++ .../terraform/terraform_provider.go | 5 +- 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/cli/azd/cmd/down_test.go b/cli/azd/cmd/down_test.go index 986b4d51246..36ae2fbb247 100644 --- a/cli/azd/cmd/down_test.go +++ b/cli/azd/cmd/down_test.go @@ -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" ) @@ -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") +} diff --git a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go index e1af569377c..fea42f934e2 100644 --- a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go +++ b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go @@ -287,8 +287,9 @@ func (t *TerraformProvider) Destroy( } t.console.MessageUxItem(ctx, &ux.WarningMessage{ - Description: "No resources were deleted because --no-prompt was set in a CI/CD environment.", - Hints: []string{"Re-run with --force to delete resources without confirmation."}, + Description: "No resources were deleted for this Terraform configuration because --no-prompt " + + "was set in a CI/CD environment.", + Hints: []string{"Re-run with --force to delete resources without confirmation."}, }) return &provisioning.DestroyResult{SkippedDeletion: true}, nil From ce598b5185bd9f18f704caaae228aadff0181d44 Mon Sep 17 00:00:00 2001 From: hemarina Date: Thu, 16 Jul 2026 19:11:38 -0700 Subject: [PATCH 5/7] address the feedback, close the gap of CI & no prompt --- .../terraform/terraform_provider.go | 29 ++++---- .../terraform/terraform_provider_test.go | 66 +++++++++++++++---- 2 files changed, 70 insertions(+), 25 deletions(-) diff --git a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go index fea42f934e2..3c3d8a3cc6e 100644 --- a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go +++ b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go @@ -261,22 +261,25 @@ func (t *TerraformProvider) Destroy( modulePath := t.modulePath() - // In a CI/CD pipeline with --no-prompt, azd cannot answer terraform's interactive prompts. Initialize - // the backend non-interactively (-input=false) up front so that both the destroy 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). This only affects the automated CI path. - automatedCI := resource.IsRunningOnCI() && t.console.IsNoPromptMode() - if automatedCI { + // 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. + automated := resource.IsRunningOnCI() || t.console.IsNoPromptMode() + if automated { if initRes, err := t.init(ctx, isRemoteBackendConfig, "-input=false"); err != nil { return nil, fmt.Errorf("terraform init failed: %s, err: %w", initRes, err) } } - // terraform destroy asks for confirmation interactively unless --force is passed. In a CI/CD pipeline - // with --no-prompt, azd cannot answer that prompt, so terraform would block on stdin and hang. Instead, - // preview the destroy plan (read-only) and stop without deleting, as if the confirmation were answered - // "no". Use --force to delete in CI. See issue #4317. - if automatedCI && !options.Force() { + // 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() { 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) @@ -287,8 +290,8 @@ func (t *TerraformProvider) Destroy( } t.console.MessageUxItem(ctx, &ux.WarningMessage{ - Description: "No resources were deleted for this Terraform configuration because --no-prompt " + - "was set in a CI/CD environment.", + 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."}, }) diff --git a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go index 70e81a85f1d..6caab918dcc 100644 --- a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go +++ b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go @@ -81,9 +81,9 @@ func TestTerraformDestroy(t *testing.T) { require.Contains(t, destroyResult.InvalidatedEnvKeys, "RG_NAME") } -// TestTerraformDestroyCIPreviewsWithoutDeleting verifies that `azd down --no-prompt` in a CI/CD -// environment (without --force) previews the destroy plan and does NOT delete anything. terraform's -// interactive destroy confirmation would otherwise block on stdin and hang. See issue #4317. +// TestTerraformDestroyCIPreviewsWithoutDeleting verifies that in a CI/CD environment (even without an +// explicit --no-prompt) `azd down` without --force previews the destroy plan and does NOT delete anything. +// terraform's interactive destroy confirmation would otherwise block on stdin and hang. See issue #4317. func TestTerraformDestroyCIPreviewsWithoutDeleting(t *testing.T) { skipIfTerraformNotInstalled(t) t.Setenv("TF_BUILD", "True") // simulate an Azure Pipelines (CI) run @@ -92,6 +92,39 @@ func TestTerraformDestroyCIPreviewsWithoutDeleting(t *testing.T) { prepareGenericMocks(mockContext.CommandRunner) preparePlanningMocks(mockContext.CommandRunner) + // The preview path must initialize the backend first so `terraform plan -destroy` works on a fresh agent. + initCalled := false + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return args.Cmd == "terraform" && strings.Contains(command, "init") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + initCalled = true + require.Contains(t, args.Args, "-input=false") + return exec.RunResult{Stdout: "Terraform has been successfully initialized!"}, nil + }) + + infraProvider := createTerraformProvider(t, mockContext) + + destroyOptions := provisioning.NewDestroyOptions(false, false) + destroyResult, err := infraProvider.Destroy(*mockContext.Context, destroyOptions) + + require.NoError(t, err) + require.NotNil(t, destroyResult) + require.True(t, destroyResult.SkippedDeletion) + require.True(t, initCalled) + require.Empty(t, destroyResult.InvalidatedEnvKeys) +} + +// TestTerraformDestroyNoPromptPreviewsWithoutDeleting verifies that --no-prompt outside CI (for example an +// AI agent or an explicit --no-prompt in a script) also previews the destroy plan and does NOT delete, +// rather than hanging on terraform's confirmation. +func TestTerraformDestroyNoPromptPreviewsWithoutDeleting(t *testing.T) { + skipIfTerraformNotInstalled(t) + clearCIEnv(t) + + mockContext := mocks.NewMockContext(t.Context()) + prepareGenericMocks(mockContext.CommandRunner) + preparePlanningMocks(mockContext.CommandRunner) + infraProvider := createTerraformProvider(t, mockContext) mockContext.Console.SetNoPromptMode(true) @@ -104,9 +137,10 @@ func TestTerraformDestroyCIPreviewsWithoutDeleting(t *testing.T) { require.Empty(t, destroyResult.InvalidatedEnvKeys) } -// TestTerraformDestroyCIWithForceDeletes verifies that --force bypasses the CI preview so that -// `azd down --no-prompt --force` still tears resources down (terraform gets -auto-approve). -func TestTerraformDestroyCIWithForceDeletes(t *testing.T) { +// TestTerraformDestroyCIForceDeletes verifies that --force in CI (without --no-prompt) still initializes the +// backend and tears resources down. This closes the "backend initialization required" gap for a standalone +// teardown pipeline running `azd down --force` on a fresh agent (#4317). +func TestTerraformDestroyCIForceDeletes(t *testing.T) { skipIfTerraformNotInstalled(t) t.Setenv("TF_BUILD", "True") @@ -127,7 +161,6 @@ func TestTerraformDestroyCIWithForceDeletes(t *testing.T) { }) infraProvider := createTerraformProvider(t, mockContext) - mockContext.Console.SetNoPromptMode(true) destroyOptions := provisioning.NewDestroyOptions(true, false) destroyResult, err := infraProvider.Destroy(*mockContext.Context, destroyOptions) @@ -140,19 +173,27 @@ func TestTerraformDestroyCIWithForceDeletes(t *testing.T) { require.Contains(t, destroyResult.InvalidatedEnvKeys, "RG_NAME") } -// TestTerraformDestroyNoPromptNotCIDeletes verifies that --no-prompt alone (outside CI) does not trigger -// the preview path — behavior is unchanged and terraform destroy is invoked. -func TestTerraformDestroyNoPromptNotCIDeletes(t *testing.T) { +// TestTerraformDestroyInteractiveDeletes verifies that in an interactive terminal (not CI, not --no-prompt, +// no --force) the behavior is unchanged: azd does not force init or preview, and terraform destroy is +// invoked so the user answers terraform's own confirmation. +func TestTerraformDestroyInteractiveDeletes(t *testing.T) { skipIfTerraformNotInstalled(t) clearCIEnv(t) mockContext := mocks.NewMockContext(t.Context()) prepareGenericMocks(mockContext.CommandRunner) - preparePlanningMocks(mockContext.CommandRunner) prepareDestroyMocks(mockContext.CommandRunner) + // init must NOT be run by azd on the interactive path (unchanged from shipped behavior). + initCalled := false + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return args.Cmd == "terraform" && strings.Contains(command, "init") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + initCalled = true + return exec.RunResult{Stdout: "Terraform has been successfully initialized!"}, nil + }) + infraProvider := createTerraformProvider(t, mockContext) - mockContext.Console.SetNoPromptMode(true) destroyOptions := provisioning.NewDestroyOptions(false, false) destroyResult, err := infraProvider.Destroy(*mockContext.Context, destroyOptions) @@ -160,6 +201,7 @@ func TestTerraformDestroyNoPromptNotCIDeletes(t *testing.T) { require.NoError(t, err) require.NotNil(t, destroyResult) require.False(t, destroyResult.SkippedDeletion) + require.False(t, initCalled) require.Contains(t, destroyResult.InvalidatedEnvKeys, "AZURE_LOCATION") } From 3e7064652d4ebb6c16a63414314f5551361b214a Mon Sep 17 00:00:00 2001 From: hemarina Date: Fri, 17 Jul 2026 09:50:58 -0700 Subject: [PATCH 6/7] update tests, address feedback --- .../terraform/terraform_provider_test.go | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go index 6caab918dcc..1e8fa827bf1 100644 --- a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go +++ b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go @@ -65,6 +65,9 @@ func TestTerraformPlan(t *testing.T) { func TestTerraformDestroy(t *testing.T) { skipIfTerraformNotInstalled(t) + // Clear CI variables so this interactive-destroy test does not inherit the runner's CI state and take + // the non-interactive preview branch (which would skip deletion and fail the assertions below). + clearCIEnv(t) mockContext := mocks.NewMockContext(t.Context()) prepareGenericMocks(mockContext.CommandRunner) preparePlanningMocks(mockContext.CommandRunner) @@ -102,6 +105,19 @@ func TestTerraformDestroyCIPreviewsWithoutDeleting(t *testing.T) { return exec.RunResult{Stdout: "Terraform has been successfully initialized!"}, nil }) + // The core of #4317: the preview must actually run `terraform plan -destroy` (read-only) rather than + // silently returning SkippedDeletion. Registered after preparePlanningMocks so it wins (LIFO) for the + // destroy-plan call. + planDestroyCalled := false + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return args.Cmd == "terraform" && + strings.Contains(command, "plan") && strings.Contains(command, "-destroy") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + planDestroyCalled = true + require.Contains(t, args.Args, "-destroy") + return exec.RunResult{Stdout: "Plan: 0 to add, 0 to change, 1 to destroy."}, nil + }) + infraProvider := createTerraformProvider(t, mockContext) destroyOptions := provisioning.NewDestroyOptions(false, false) @@ -111,6 +127,7 @@ func TestTerraformDestroyCIPreviewsWithoutDeleting(t *testing.T) { require.NotNil(t, destroyResult) require.True(t, destroyResult.SkippedDeletion) require.True(t, initCalled) + require.True(t, planDestroyCalled) require.Empty(t, destroyResult.InvalidatedEnvKeys) } @@ -128,12 +145,25 @@ func TestTerraformDestroyNoPromptPreviewsWithoutDeleting(t *testing.T) { infraProvider := createTerraformProvider(t, mockContext) mockContext.Console.SetNoPromptMode(true) + // Assert the preview actually runs `terraform plan -destroy`; without this the test would pass even if + // the provider returned SkippedDeletion without previewing anything. + planDestroyCalled := false + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return args.Cmd == "terraform" && + strings.Contains(command, "plan") && strings.Contains(command, "-destroy") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + planDestroyCalled = true + require.Contains(t, args.Args, "-destroy") + return exec.RunResult{Stdout: "Plan: 0 to add, 0 to change, 1 to destroy."}, nil + }) + destroyOptions := provisioning.NewDestroyOptions(false, false) destroyResult, err := infraProvider.Destroy(*mockContext.Context, destroyOptions) require.NoError(t, err) require.NotNil(t, destroyResult) require.True(t, destroyResult.SkippedDeletion) + require.True(t, planDestroyCalled) require.Empty(t, destroyResult.InvalidatedEnvKeys) } From 8f3055220e99e0d616b10b26fa0c50e6f1cc7793 Mon Sep 17 00:00:00 2001 From: hemarina Date: Fri, 17 Jul 2026 10:10:21 -0700 Subject: [PATCH 7/7] address feedback --- cli/azd/cmd/down.go | 3 +++ cli/azd/cmd/testdata/TestUsage-azd-down.snap | 1 + .../pkg/infra/provisioning/terraform/terraform_provider.go | 6 ++++-- .../infra/provisioning/terraform/terraform_provider_test.go | 4 ++++ cli/azd/pkg/tools/terraform/terraform.go | 4 +++- cli/azd/pkg/tools/terraform/terraform_test.go | 4 ++-- 6 files changed, 17 insertions(+), 5 deletions(-) diff --git a/cli/azd/cmd/down.go b/cli/azd/cmd/down.go index 57eab871912..78b5cf2d182 100644 --- a/cli/azd/cmd/down.go +++ b/cli/azd/cmd/down.go @@ -181,6 +181,9 @@ func getCmdDownHelpDescription(*cobra.Command) string { " files on your local machine.", output.WithHighLightFormat("azd down")), []string{ "When 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.", }) } diff --git a/cli/azd/cmd/testdata/TestUsage-azd-down.snap b/cli/azd/cmd/testdata/TestUsage-azd-down.snap index 6ca40df17cf..f65eecf0e21 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-down.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-down.snap @@ -2,6 +2,7 @@ Delete Azure resources for an application. Running azd down will not delete application files on your local machine. When 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 [] [flags] diff --git a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go index 3c3d8a3cc6e..ecc4091d624 100644 --- a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go +++ b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go @@ -152,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) } @@ -268,7 +269,8 @@ func (t *TerraformProvider) Destroy( // 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. + // 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() if automated { if initRes, err := t.init(ctx, isRemoteBackendConfig, "-input=false"); err != nil { diff --git a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go index 1e8fa827bf1..95df8ecafce 100644 --- a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go +++ b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider_test.go @@ -102,6 +102,8 @@ func TestTerraformDestroyCIPreviewsWithoutDeleting(t *testing.T) { }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { initCalled = true require.Contains(t, args.Args, "-input=false") + // A teardown must not upgrade providers/modules or rewrite the lock file (#4317). + require.NotContains(t, args.Args, "-upgrade") return exec.RunResult{Stdout: "Terraform has been successfully initialized!"}, nil }) @@ -187,6 +189,8 @@ func TestTerraformDestroyCIForceDeletes(t *testing.T) { }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { initCalled = true require.Contains(t, args.Args, "-input=false") + // A teardown must not upgrade providers/modules or rewrite the lock file (#4317). + require.NotContains(t, args.Args, "-upgrade") return exec.RunResult{Stdout: "Terraform has been successfully initialized!"}, nil }) diff --git a/cli/azd/pkg/tools/terraform/terraform.go b/cli/azd/pkg/tools/terraform/terraform.go index 5c7cd347d04..707d135ab3d 100644 --- a/cli/azd/pkg/tools/terraform/terraform.go +++ b/cli/azd/pkg/tools/terraform/terraform.go @@ -122,11 +122,13 @@ func (cli *Cli) Validate(ctx context.Context, modulePath string) (string, error) return cmdRes.Stdout, nil } +// Init runs `terraform init`. Callers control whether providers/modules are upgraded by passing +// `-upgrade` (the provisioning path does); destroy paths omit it so teardown does not unexpectedly +// select newer providers or rewrite `.terraform.lock.hcl`. func (cli *Cli) Init(ctx context.Context, modulePath string, additionalArgs ...string) (string, error) { args := []string{ fmt.Sprintf("-chdir=%s", modulePath), "init", - "-upgrade", } args = append(args, additionalArgs...) diff --git a/cli/azd/pkg/tools/terraform/terraform_test.go b/cli/azd/pkg/tools/terraform/terraform_test.go index 0a9905a76b1..a50adc3d18a 100644 --- a/cli/azd/pkg/tools/terraform/terraform_test.go +++ b/cli/azd/pkg/tools/terraform/terraform_test.go @@ -293,7 +293,7 @@ func getCommandTestCases() []commandTestCase { { name: "Init", invoke: func(cli *Cli, ctx context.Context) (string, error) { - return cli.Init(ctx, "/module") + return cli.Init(ctx, "/module", "-upgrade") }, expectedArgs: []string{"-chdir=/module", "init", "-upgrade"}, interactive: true, @@ -421,7 +421,7 @@ func Test_Commands_AdditionalArgs(t *testing.T) { require.NoError(t, err) require.Equal(t, []string{ - "-chdir=/module", "init", "-upgrade", "-backend=false", "-input=false", + "-chdir=/module", "init", "-backend=false", "-input=false", }, capturedArgs.Args) })