From cc3a2d83bb13c586c63d57b4191a87cfd502aa36 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Sun, 13 Jul 2025 13:03:59 -0400 Subject: [PATCH] feature(terraform): Support refresh flags and on stack --- pkg/env/terraform_env.go | 8 ++++- pkg/env/terraform_env_test.go | 3 ++ pkg/stack/windsor_stack.go | 29 ++++++++++++----- pkg/stack/windsor_stack_test.go | 58 +++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 10 deletions(-) diff --git a/pkg/env/terraform_env.go b/pkg/env/terraform_env.go index f8fcb8195..85fa857ae 100644 --- a/pkg/env/terraform_env.go +++ b/pkg/env/terraform_env.go @@ -20,13 +20,14 @@ import ( // Types // ============================================================================= -// TerraformArgs contains the generated terraform arguments and environment variables +// TerraformArgs contains all the arguments needed for terraform operations type TerraformArgs struct { ModulePath string TFDataDir string InitArgs []string PlanArgs []string ApplyArgs []string + RefreshArgs []string ImportArgs []string DestroyArgs []string PlanDestroyArgs []string @@ -160,6 +161,9 @@ func (e *TerraformEnvPrinter) GenerateTerraformArgs(projectPath, modulePath stri applyArgs := []string{tfPlanPath} + refreshArgs := []string{} + refreshArgs = append(refreshArgs, varFileArgs...) + planDestroyArgs := []string{"-destroy"} planDestroyArgs = append(planDestroyArgs, varFileArgs...) @@ -171,6 +175,7 @@ func (e *TerraformEnvPrinter) GenerateTerraformArgs(projectPath, modulePath stri terraformVars["TF_CLI_ARGS_init"] = strings.TrimSpace(fmt.Sprintf("-backend=true %s", strings.Join(backendConfigArgsForEnv, " "))) terraformVars["TF_CLI_ARGS_plan"] = strings.TrimSpace(fmt.Sprintf("-out=\"%s\" %s", tfPlanPath, strings.Join(varFileArgsForEnv, " "))) terraformVars["TF_CLI_ARGS_apply"] = strings.TrimSpace(fmt.Sprintf("\"%s\"", tfPlanPath)) + terraformVars["TF_CLI_ARGS_refresh"] = strings.TrimSpace(strings.Join(varFileArgsForEnv, " ")) terraformVars["TF_CLI_ARGS_import"] = strings.TrimSpace(strings.Join(varFileArgsForEnv, " ")) terraformVars["TF_CLI_ARGS_destroy"] = strings.TrimSpace(strings.Join(varFileArgsForEnv, " ")) terraformVars["TF_VAR_context_path"] = strings.TrimSpace(filepath.ToSlash(configRoot)) @@ -189,6 +194,7 @@ func (e *TerraformEnvPrinter) GenerateTerraformArgs(projectPath, modulePath stri InitArgs: initArgs, PlanArgs: planArgs, ApplyArgs: applyArgs, + RefreshArgs: refreshArgs, ImportArgs: varFileArgs, DestroyArgs: destroyArgs, PlanDestroyArgs: planDestroyArgs, diff --git a/pkg/env/terraform_env_test.go b/pkg/env/terraform_env_test.go index 9c26bfde8..03d8bbcfd 100644 --- a/pkg/env/terraform_env_test.go +++ b/pkg/env/terraform_env_test.go @@ -479,6 +479,9 @@ func TestTerraformEnv_Print(t *testing.T) { filepath.Join(configRoot, "terraform/project/path.tfvars"), filepath.Join(configRoot, "terraform/project/path.tfvars.json")), "TF_CLI_ARGS_apply": fmt.Sprintf(`"%s"`, filepath.Join(configRoot, ".terraform/project/path/terraform.tfplan")), + "TF_CLI_ARGS_refresh": fmt.Sprintf(`-var-file="%s" -var-file="%s"`, + filepath.Join(configRoot, "terraform/project/path.tfvars"), + filepath.Join(configRoot, "terraform/project/path.tfvars.json")), "TF_CLI_ARGS_import": fmt.Sprintf(`-var-file="%s" -var-file="%s"`, filepath.Join(configRoot, "terraform/project/path.tfvars"), filepath.Join(configRoot, "terraform/project/path.tfvars.json")), diff --git a/pkg/stack/windsor_stack.go b/pkg/stack/windsor_stack.go index a0cd07d09..63a352334 100644 --- a/pkg/stack/windsor_stack.go +++ b/pkg/stack/windsor_stack.go @@ -121,6 +121,12 @@ func (s *WindsorStack) Up() error { return fmt.Errorf("error running terraform init for %s: %w", component.Path, err) } + // Run terraform refresh to sync state with actual infrastructure + // This is tolerant of failures for non-existent state + refreshArgs := []string{fmt.Sprintf("-chdir=%s", terraformArgs.ModulePath), "refresh"} + refreshArgs = append(refreshArgs, terraformArgs.RefreshArgs...) + _, _ = s.shell.ExecProgress(fmt.Sprintf("🔄 Refreshing Terraform state in %s", component.Path), "terraform", refreshArgs...) + planArgs := []string{fmt.Sprintf("-chdir=%s", terraformArgs.ModulePath), "plan"} planArgs = append(planArgs, terraformArgs.PlanArgs...) _, err = s.shell.ExecProgress(fmt.Sprintf("🌎 Planning Terraform changes in %s", component.Path), "terraform", planArgs...) @@ -146,10 +152,10 @@ func (s *WindsorStack) Up() error { return nil } -// Down destroys the stack of components by running Terraform destroy operations. -// It processes components in reverse order, generating terraform arguments, running -// Terraform plan with destroy flag, and destroy operations, and cleaning up backend override files. -// The method ensures proper directory management and terraform argument setup for each component. +// Down destroys all Terraform components in the stack by executing Terraform destroy operations in reverse dependency order. +// For each component, Down generates Terraform arguments, sets required environment variables, unsets conflicting TF_CLI_ARGS_* variables, +// creates backend override files, runs Terraform refresh, plan (with destroy flag), and destroy commands, and removes backend override files. +// Components with Destroy set to false are skipped. Directory state is restored after execution. Errors are returned on any operation failure. func (s *WindsorStack) Down() error { currentDir, err := s.shims.Getwd() if err != nil { @@ -165,6 +171,10 @@ func (s *WindsorStack) Down() error { for i := len(components) - 1; i >= 0; i-- { component := components[i] + if component.Destroy != nil && !*component.Destroy { + continue + } + if _, err := s.shims.Stat(component.FullPath); os.IsNotExist(err) { continue } @@ -174,8 +184,6 @@ func (s *WindsorStack) Down() error { return fmt.Errorf("error generating terraform args for %s: %w", component.Path, err) } - // Set terraform environment variables (TF_VAR_* and TF_DATA_DIR) - // First, unset any existing TF_CLI_ARGS_* environment variables to avoid conflicts tfCliArgsVars := []string{"TF_CLI_ARGS_init", "TF_CLI_ARGS_plan", "TF_CLI_ARGS_apply", "TF_CLI_ARGS_destroy", "TF_CLI_ARGS_import"} for _, envVar := range tfCliArgsVars { if err := s.shims.Unsetenv(envVar); err != nil { @@ -191,20 +199,23 @@ func (s *WindsorStack) Down() error { } } - // Create backend_override.tf file in the component directory if err := s.terraformEnv.PostEnvHook(component.FullPath); err != nil { return fmt.Errorf("error creating backend override file for %s: %w", component.Path, err) } + refreshArgs := []string{fmt.Sprintf("-chdir=%s", terraformArgs.ModulePath), "refresh"} + refreshArgs = append(refreshArgs, terraformArgs.RefreshArgs...) + _, _ = s.shell.ExecProgress(fmt.Sprintf("🔄 Refreshing Terraform state in %s", component.Path), "terraform", refreshArgs...) + planArgs := []string{fmt.Sprintf("-chdir=%s", terraformArgs.ModulePath), "plan"} planArgs = append(planArgs, terraformArgs.PlanDestroyArgs...) - if _, err := s.shell.ExecProgress(fmt.Sprintf("🗑️ Planning terraform destroy for %s", component.Path), "terraform", planArgs...); err != nil { + if _, err := s.shell.ExecProgress(fmt.Sprintf("🗑️ Planning terraform destroy for %s", component.Path), "terraform", planArgs...); err != nil { return fmt.Errorf("error running terraform plan destroy for %s: %w", component.Path, err) } destroyArgs := []string{fmt.Sprintf("-chdir=%s", terraformArgs.ModulePath), "destroy"} destroyArgs = append(destroyArgs, terraformArgs.DestroyArgs...) - if _, err := s.shell.ExecProgress(fmt.Sprintf("🗑️ Destroying terraform for %s", component.Path), "terraform", destroyArgs...); err != nil { + if _, err := s.shell.ExecProgress(fmt.Sprintf("🗑️ Destroying terraform for %s", component.Path), "terraform", destroyArgs...); err != nil { return fmt.Errorf("error running terraform destroy for %s: %w", component.Path, err) } diff --git a/pkg/stack/windsor_stack_test.go b/pkg/stack/windsor_stack_test.go index 3b93eb344..0a0d7bacf 100644 --- a/pkg/stack/windsor_stack_test.go +++ b/pkg/stack/windsor_stack_test.go @@ -392,6 +392,64 @@ func TestWindsorStack_Down(t *testing.T) { } }) + t.Run("SkipComponentsWithDestroyFalse", func(t *testing.T) { + stack, mocks := setup(t) + + // Set up components with one having destroy: false + destroyFalse := false + mocks.Blueprint.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{ + { + Source: "source1", + Path: "module/path1", + FullPath: filepath.Join(os.Getenv("WINDSOR_PROJECT_ROOT"), ".windsor", ".tf_modules", "remote", "path1"), + Destroy: &destroyFalse, // This component should be skipped + }, + { + Source: "source2", + Path: "module/path2", + FullPath: filepath.Join(os.Getenv("WINDSOR_PROJECT_ROOT"), ".windsor", ".tf_modules", "remote", "path2"), + // Destroy defaults to true, so this should be destroyed + }, + } + } + + // Track terraform commands executed + var terraformCommands []string + mocks.Shell.ExecProgressFunc = func(message string, command string, args ...string) (string, error) { + if command == "terraform" && len(args) > 1 { + terraformCommands = append(terraformCommands, fmt.Sprintf("%s %s", args[0], args[1])) + } + return "", nil + } + + // When Down is called + if err := stack.Down(); err != nil { + t.Errorf("Expected Down to return nil, got %v", err) + } + + // Then only the component without destroy: false should be destroyed + // We should see terraform commands for path2 but not path1 + foundPath1Commands := false + foundPath2Commands := false + + for _, cmd := range terraformCommands { + if strings.Contains(cmd, "path1") { + foundPath1Commands = true + } + if strings.Contains(cmd, "path2") { + foundPath2Commands = true + } + } + + if foundPath1Commands { + t.Errorf("Expected no terraform commands for path1 (destroy: false), but found commands") + } + if !foundPath2Commands { + t.Errorf("Expected terraform commands for path2 (destroy: true), but found none") + } + }) + t.Run("ErrorRunningTerraformDestroy", func(t *testing.T) { stack, mocks := setup(t) mocks.Shell.ExecProgressFunc = func(message string, command string, args ...string) (string, error) {