- 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
312 lines
No EOL
8 KiB
Go
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()
|
|
} |