// Package destroy handles tearing down obm deployments. package destroy import ( "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "github.com/openboatmobile/obm/internal/prompt" ) // Options configures the destroy operation. type Options struct { WorkDir string // Working directory (default: current) AutoApprove bool // Skip confirmation prompt VarFiles []string // Additional var files to load EnvFiles []string // Additional env files to load KeepState bool // Don't delete state files after destroy } // Result holds the outcome of a destroy operation. type Result struct { Resources []Resource // Resources that were destroyed Duration string // How long the operation took } // Resource represents a single destroyed resource. type Resource struct { Address string // e.g., "hcloud_server.main" Type string // e.g., "hcloud_server" Name string // e.g., "main" } // State represents the terraform.tfstate structure (minimal fields for resource extraction). type State struct { Resources []StateResource `json:"resources"` } // StateResource is a resource entry in terraform state. type StateResource struct { Address string `json:"address"` Type string `json:"type"` Name string `json:"name"` Module string `json:"module,omitempty"` } // Run executes the destroy workflow. func Run(opts *Options) error { if opts == nil { opts = &Options{} } // Determine working directory workDir := opts.WorkDir if workDir == "" { cwd, err := os.Getwd() if err != nil { return fmt.Errorf("getting current directory: %w", err) } workDir = cwd } // Check for terraform files tfDir := filepath.Join(workDir, ".terraform") tfState := filepath.Join(workDir, "terraform.tfstate") if !fileExists(tfState) && !dirExists(tfDir) { prompt.Warn("No Terraform state found in " + workDir) prompt.Info("Run 'obm deploy' first to create infrastructure") return nil } // Load and display resources that will be destroyed resources, err := listResourcesFromState(tfState) if err != nil { prompt.Warn("Could not read state: " + err.Error()) prompt.Info("Proceeding with destroy anyway...") resources = []Resource{} } // Display what will be destroyed displayDestroyPlan(resources) // Confirmation if !opts.AutoApprove { if !prompt.Confirm("This will destroy all listed resources. Continue?", false) { prompt.Info("Destroy cancelled") return nil } } // Run terraform destroy prompt.Header("🔧 Destroying Infrastructure") if err := runTerraformDestroy(workDir, opts); err != nil { return fmt.Errorf("terraform destroy failed: %w", err) } prompt.Success("Infrastructure destroyed successfully") // Clean up state files unless asked to keep if !opts.KeepState { cleanupStateFiles(workDir) } return nil } // listResourcesFromState extracts resources from terraform.tfstate. func listResourcesFromState(statePath string) ([]Resource, error) { data, err := os.ReadFile(statePath) if err != nil { return nil, fmt.Errorf("reading state: %w", err) } // Handle empty state file if len(data) == 0 { return nil, nil } var state State // Try parsing as JSON var rawState map[string]interface{} if err := json.Unmarshal(data, &rawState); err != nil { return nil, fmt.Errorf("parsing state JSON: %w", err) } // Handle different state file versions if resources, ok := rawState["resources"].([]interface{}); ok { for _, r := range resources { if resMap, ok := r.(map[string]interface{}); ok { res := Resource{} if addr, ok := resMap["address"].(string); ok { res.Address = addr } if t, ok := resMap["type"].(string); ok { res.Type = t } if n, ok := resMap["name"].(string); ok { res.Name = n } // Handle module path if module, ok := resMap["module"].(string); ok && module != "" { res.Address = module + "." + res.Address } state.Resources = append(state.Resources, StateResource{ Address: res.Address, Type: res.Type, Name: res.Name, }) } } } result := make([]Resource, 0, len(state.Resources)) for _, sr := range state.Resources { result = append(result, Resource{ Address: sr.Address, Type: sr.Type, Name: sr.Name, }) } return result, nil } // displayDestroyPlan shows what will be destroyed. func displayDestroyPlan(resources []Resource) { prompt.Header("⚠️ Destroy Plan") prompt.Divider() if len(resources) == 0 { prompt.Info("No managed resources found in state") prompt.Warn("Terraform may still destroy resources tracked remotely") return } // Group by type byType := make(map[string][]Resource) for _, r := range resources { byType[r.Type] = append(byType[r.Type], r) } fmt.Printf("\n %-25s %s\n", "Resource Type", "Count") fmt.Printf(" %-25s %s\n", "─────────────", "─────") for typ, res := range byType { fmt.Printf(" %-25s %d\n", typ, len(res)) } prompt.Divider() fmt.Printf("\n Total resources to destroy: %d\n\n", len(resources)) for _, r := range resources { fmt.Printf(" - %s\n", r.Address) } fmt.Println() } // runTerraformDestroy executes terraform destroy. func runTerraformDestroy(workDir string, opts *Options) error { args := []string{"destroy", "-auto-approve"} // Add var files for _, vf := range opts.VarFiles { args = append(args, "-var-file", vf) } cmd := exec.Command("terraform", args...) cmd.Dir = workDir // Stream output to stdout/stderr cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr // Load environment from env files env := os.Environ() for _, ef := range opts.EnvFiles { envVars, err := loadEnvFile(ef) if err != nil { prompt.Warn("Could not load env file " + ef + ": " + err.Error()) continue } env = append(env, envVars...) } cmd.Env = env return cmd.Run() } // loadEnvFile reads a .env file and returns KEY=value strings. func loadEnvFile(path string) ([]string, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var result []string lines := strings.Split(string(data), "\n") for _, line := range lines { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } // Basic KEY=value parsing (handle quoted values) parts := strings.SplitN(line, "=", 2) if len(parts) == 2 { key := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) // Remove surrounding quotes if len(value) >= 2 && (value[0] == '"' || value[0] == '\'') && value[0] == value[len(value)-1] { value = value[1 : len(value)-1] } result = append(result, fmt.Sprintf("%s=%s", key, value)) } } return result, nil } // cleanupStateFiles removes terraform state files after successful destroy. func cleanupStateFiles(workDir string) { stateFiles := []string{ "terraform.tfstate", "terraform.tfstate.backup", "terraform.tfstate.backup-info", } // Remove state files for _, f := range stateFiles { path := filepath.Join(workDir, f) if fileExists(path) { if err := os.Remove(path); err != nil { prompt.Warn("Could not remove " + f + ": " + err.Error()) } else { prompt.Info("Removed " + f) } } } // Remove .terraform directory tfDir := filepath.Join(workDir, ".terraform") if dirExists(tfDir) { if err := os.RemoveAll(tfDir); err != nil { prompt.Warn("Could not remove .terraform: " + err.Error()) } else { prompt.Info("Removed .terraform directory") } } // Remove .terraform.lock.hcl lockFile := filepath.Join(workDir, ".terraform.lock.hcl") if fileExists(lockFile) { if err := os.Remove(lockFile); err != nil { prompt.Warn("Could not remove .terraform.lock.hcl: " + err.Error()) } else { prompt.Info("Removed .terraform.lock.hcl") } } } // fileExists returns true if the path is an existing file. func fileExists(path string) bool { info, err := os.Stat(path) return err == nil && !info.IsDir() } // dirExists returns true if the path is an existing directory. func dirExists(path string) bool { info, err := os.Stat(path) return err == nil && info.IsDir() }