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

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