obm/internal/destroy/destroy.go
MermaidMan 33d9a2cb2e deploy walkthrough, API validation, inference client, Hetzner provider
- Interactive deploy command with 8-step walkthrough:
  framework → provider → token → SSH → server → inference → tailscale → discord
- .env file generation from walkthrough config
- DeploymentConfig struct with framework-aware defaults
- Inference API client with validation for Venice, OpenRouter, OpenAI, Anthropic
- Hetzner Cloud provider: token validation, SSH key listing
- DotEnv parser/writer with schema validation
- Destroy command with confirmation prompt
- Validation subcommand for checking existing .env files
- All tests passing, go vet clean
2026-05-22 15:29:27 +00:00

312 lines
No EOL
8 KiB
Go

// 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()
}