diff --git a/cli/azd/cmd/down.go b/cli/azd/cmd/down.go index 27c854fca80..78b5cf2d182 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,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 } } - // 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))), @@ -172,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/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/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/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 6a8596152c1..ecc4091d624 100644 --- a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go +++ b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go @@ -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" @@ -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) } @@ -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() + 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) + } + } + + // 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) + + 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 { @@ -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{} @@ -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 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..95df8ecafce 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) @@ -81,6 +84,179 @@ func TestTerraformDestroy(t *testing.T) { require.Contains(t, destroyResult.InvalidatedEnvKeys, "RG_NAME") } +// 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 + + mockContext := mocks.NewMockContext(t.Context()) + 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") + // 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 + }) + + // 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) + 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.True(t, planDestroyCalled) + 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) + + // 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) +} + +// 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") + + mockContext := mocks.NewMockContext(t.Context()) + prepareGenericMocks(mockContext.CommandRunner) + 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") + // 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 + }) + + infraProvider := createTerraformProvider(t, mockContext) + + 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.True(t, initCalled) + require.Contains(t, destroyResult.InvalidatedEnvKeys, "AZURE_LOCATION") + require.Contains(t, destroyResult.InvalidatedEnvKeys, "RG_NAME") +} + +// 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) + 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) + + 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.False(t, initCalled) + 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..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...) @@ -234,3 +236,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 +} diff --git a/cli/azd/pkg/tools/terraform/terraform_test.go b/cli/azd/pkg/tools/terraform/terraform_test.go index d7a5b708eb8..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, @@ -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", + }, } } @@ -412,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) }) @@ -515,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) {