Initial commit: Disaster recovery CLI tool

A Go-based CLI tool for recovering servers from backups to new cloud VMs.

Features:
- Multi-cloud support: Exoscale, Cloudscale, Hetzner Cloud
- Backup sources: Local filesystem, Hetzner Storage Box
- 6-stage restore pipeline with /etc whitelist protection
- DNS migration with safety checks and auto-rollback
- Dry-run by default, requires --yes to execute
- Cloud-init for SSH key injection

Packages:
- cmd/recover-server: CLI commands (recover, migrate-dns, list, cleanup)
- internal/providers: Cloud provider implementations
- internal/backup: Backup source implementations
- internal/restore: 6-stage restore pipeline
- internal/dns: Exoscale DNS management
- internal/ui: Prompts, progress, dry-run display
- internal/config: Environment and host configuration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Olaf Berberich
2025-12-08 00:31:27 +00:00
commit 29a2886402
26 changed files with 3826 additions and 0 deletions

108
internal/ui/dryrun.go Normal file
View File

@@ -0,0 +1,108 @@
package ui
import (
"fmt"
)
// DryRun tracks dry-run mode operations
type DryRun struct {
Enabled bool
Operations []DryRunOp
}
// DryRunOp represents a single operation that would be performed
type DryRunOp struct {
Component string // e.g., "VM", "DNS", "Restore"
Action string // e.g., "Create", "Update", "Delete"
Description string
}
// NewDryRun creates a dry-run tracker
func NewDryRun(enabled bool) *DryRun {
return &DryRun{
Enabled: enabled,
Operations: make([]DryRunOp, 0),
}
}
// AddOperation records an operation
func (d *DryRun) AddOperation(component, action, description string) {
d.Operations = append(d.Operations, DryRunOp{
Component: component,
Action: action,
Description: description,
})
}
// Print displays all recorded operations
func (d *DryRun) Print() {
if !d.Enabled {
return
}
fmt.Println("\n" + HeaderLine("DRY RUN - No changes will be made"))
fmt.Println()
if len(d.Operations) == 0 {
fmt.Println("No operations would be performed.")
return
}
for i, op := range d.Operations {
fmt.Printf("%d. [%s] %s: %s\n", i+1, op.Component, op.Action, op.Description)
}
fmt.Println()
fmt.Println("To execute these operations, run with --yes flag")
}
// HeaderLine creates a formatted header
func HeaderLine(title string) string {
return fmt.Sprintf("=== %s ===", title)
}
// TablePrint prints data as a simple table
func TablePrint(headers []string, rows [][]string) {
// Calculate column widths
widths := make([]int, len(headers))
for i, h := range headers {
widths[i] = len(h)
}
for _, row := range rows {
for i, cell := range row {
if i < len(widths) && len(cell) > widths[i] {
widths[i] = len(cell)
}
}
}
// Print header
for i, h := range headers {
fmt.Printf("%-*s ", widths[i], h)
}
fmt.Println()
// Print separator
for i := range headers {
fmt.Printf("%s ", repeat("-", widths[i]))
}
fmt.Println()
// Print rows
for _, row := range rows {
for i, cell := range row {
if i < len(widths) {
fmt.Printf("%-*s ", widths[i], cell)
}
}
fmt.Println()
}
}
func repeat(s string, n int) string {
result := ""
for i := 0; i < n; i++ {
result += s
}
return result
}

111
internal/ui/progress.go Normal file
View File

@@ -0,0 +1,111 @@
package ui
import (
"fmt"
"strings"
"time"
)
// Progress tracks and displays progress
type Progress struct {
Total int
Current int
StartTime time.Time
Message string
}
// NewProgress creates a new progress tracker
func NewProgress(total int, message string) *Progress {
return &Progress{
Total: total,
Current: 0,
StartTime: time.Now(),
Message: message,
}
}
// Increment advances progress by one
func (p *Progress) Increment() {
p.Current++
p.Print()
}
// SetMessage updates the current message
func (p *Progress) SetMessage(msg string) {
p.Message = msg
}
// Print displays current progress
func (p *Progress) Print() {
elapsed := time.Since(p.StartTime)
percent := float64(p.Current) / float64(p.Total) * 100
bar := p.bar(20)
fmt.Printf("\r[%s] %.1f%% (%d/%d) %s - %s ",
bar, percent, p.Current, p.Total, elapsed.Round(time.Second), p.Message)
}
// Complete marks progress as done
func (p *Progress) Complete() {
p.Current = p.Total
elapsed := time.Since(p.StartTime)
fmt.Printf("\r[%s] 100%% (%d/%d) %s - Complete\n",
p.bar(20), p.Total, p.Total, elapsed.Round(time.Second))
}
func (p *Progress) bar(width int) string {
filled := int(float64(p.Current) / float64(p.Total) * float64(width))
empty := width - filled
return strings.Repeat("█", filled) + strings.Repeat("░", empty)
}
// Spinner displays a spinning indicator
type Spinner struct {
Message string
stop chan bool
done chan bool
}
// NewSpinner creates a new spinner
func NewSpinner(message string) *Spinner {
return &Spinner{
Message: message,
stop: make(chan bool),
done: make(chan bool),
}
}
// Start begins the spinner animation
func (s *Spinner) Start() {
go func() {
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
i := 0
for {
select {
case <-s.stop:
s.done <- true
return
default:
fmt.Printf("\r%s %s", frames[i%len(frames)], s.Message)
i++
time.Sleep(100 * time.Millisecond)
}
}
}()
}
// Stop stops the spinner
func (s *Spinner) Stop() {
s.stop <- true
<-s.done
fmt.Printf("\r%s\n", strings.Repeat(" ", len(s.Message)+5))
}
// StopWithMessage stops spinner with a final message
func (s *Spinner) StopWithMessage(msg string) {
s.stop <- true
<-s.done
fmt.Printf("\r%s\n", msg)
}

100
internal/ui/prompts.go Normal file
View File

@@ -0,0 +1,100 @@
package ui
import (
"bufio"
"fmt"
"os"
"strings"
)
// ConfirmAction asks for yes/no confirmation
func ConfirmAction(message string) bool {
reader := bufio.NewReader(os.Stdin)
fmt.Printf("%s [y/N]: ", message)
response, err := reader.ReadString('\n')
if err != nil {
return false
}
response = strings.TrimSpace(strings.ToLower(response))
return response == "y" || response == "yes"
}
// ConfirmHostname requires typing the hostname to confirm
func ConfirmHostname(hostname string) bool {
reader := bufio.NewReader(os.Stdin)
fmt.Printf("\n⚠ DESTRUCTIVE OPERATION ⚠️\n")
fmt.Printf("This will modify DNS for: %s\n", hostname)
fmt.Printf("Type the hostname exactly to confirm: ")
response, err := reader.ReadString('\n')
if err != nil {
return false
}
response = strings.TrimSpace(response)
return response == hostname
}
// ConfirmRecovery requires confirmation for recovery operation
func ConfirmRecovery(host, source, target string) bool {
reader := bufio.NewReader(os.Stdin)
fmt.Printf("\n=== RECOVERY CONFIRMATION ===\n")
fmt.Printf("Host: %s\n", host)
fmt.Printf("Source: %s\n", source)
fmt.Printf("Target: %s\n", target)
fmt.Printf("\nThis will create a new VM and restore data.\n")
fmt.Printf("Type 'RECOVER' to proceed: ")
response, err := reader.ReadString('\n')
if err != nil {
return false
}
response = strings.TrimSpace(response)
return response == "RECOVER"
}
// SelectOption presents options and returns selection
func SelectOption(prompt string, options []string) (int, error) {
reader := bufio.NewReader(os.Stdin)
fmt.Println(prompt)
for i, opt := range options {
fmt.Printf(" %d. %s\n", i+1, opt)
}
fmt.Print("Selection: ")
var selection int
_, err := fmt.Fscanf(reader, "%d\n", &selection)
if err != nil {
return -1, err
}
if selection < 1 || selection > len(options) {
return -1, fmt.Errorf("invalid selection")
}
return selection - 1, nil
}
// PrintError prints an error message in red
func PrintError(format string, args ...interface{}) {
fmt.Printf("\033[31mError: "+format+"\033[0m\n", args...)
}
// PrintSuccess prints a success message in green
func PrintSuccess(format string, args ...interface{}) {
fmt.Printf("\033[32m✓ "+format+"\033[0m\n", args...)
}
// PrintWarning prints a warning message in yellow
func PrintWarning(format string, args ...interface{}) {
fmt.Printf("\033[33m⚠ "+format+"\033[0m\n", args...)
}
// PrintInfo prints an info message in blue
func PrintInfo(format string, args ...interface{}) {
fmt.Printf("\033[34m "+format+"\033[0m\n", args...)
}