Files
Olaf Berberich 29a2886402 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>
2025-12-08 00:31:27 +00:00

170 lines
3.5 KiB
Go

package backup
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
)
// LocalSource implements BackupSource for local filesystem
type LocalSource struct {
BasePath string // e.g., /srv/backups
}
// NewLocalSource creates a new local backup source
func NewLocalSource(basePath string) *LocalSource {
if basePath == "" {
basePath = "/srv/backups"
}
return &LocalSource{BasePath: basePath}
}
func (s *LocalSource) Name() string {
return "local"
}
func (s *LocalSource) List(ctx context.Context, host string) ([]BackupInfo, error) {
var backups []BackupInfo
if host != "" {
// List specific host
info, err := s.getBackupInfo(host)
if err != nil {
return nil, err
}
if info != nil {
backups = append(backups, *info)
}
} else {
// List all hosts
entries, err := os.ReadDir(s.BasePath)
if err != nil {
return nil, fmt.Errorf("failed to read backup directory: %w", err)
}
for _, entry := range entries {
if entry.IsDir() {
info, err := s.getBackupInfo(entry.Name())
if err != nil {
continue // Skip invalid backups
}
if info != nil {
backups = append(backups, *info)
}
}
}
}
return backups, nil
}
func (s *LocalSource) getBackupInfo(host string) (*BackupInfo, error) {
hostPath := filepath.Join(s.BasePath, host)
stat, err := os.Stat(hostPath)
if err != nil {
return nil, err
}
info := &BackupInfo{
Host: host,
Source: "local",
Path: hostPath,
Timestamp: stat.ModTime(),
}
// Check for subdirectories
for _, dir := range SupportedDirs {
dirPath := filepath.Join(hostPath, dir)
if _, err := os.Stat(dirPath); err == nil {
switch dir {
case "root":
info.HasRoot = true
case "opt":
info.HasOpt = true
case "etc":
info.HasEtc = true
}
// Add directory size
size, _ := dirSize(dirPath)
info.SizeBytes += size
}
}
return info, nil
}
func (s *LocalSource) SyncTo(ctx context.Context, host string, targetSSH string, sshKeyPath string, dirs []string) error {
hostPath := filepath.Join(s.BasePath, host)
for _, dir := range dirs {
srcPath := filepath.Join(hostPath, dir) + "/"
// Check source exists
if _, err := os.Stat(srcPath); os.IsNotExist(err) {
continue // Skip missing directories
}
// Determine target path
var targetPath string
switch dir {
case "root":
targetPath = "/root/"
case "opt":
targetPath = "/opt/"
case "etc":
targetPath = "/srv/restore/etc/" // Stage /etc, don't overwrite directly
default:
targetPath = "/" + dir + "/"
}
// Build rsync command
args := []string{
"-avz",
"--progress",
"-e", fmt.Sprintf("ssh -i %s -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null", sshKeyPath),
srcPath,
fmt.Sprintf("%s:%s", targetSSH, targetPath),
}
cmd := exec.CommandContext(ctx, "rsync", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("rsync failed for %s: %w", dir, err)
}
}
return nil
}
func (s *LocalSource) GetPath(host string) string {
return filepath.Join(s.BasePath, host)
}
func (s *LocalSource) Validate(ctx context.Context) error {
if _, err := os.Stat(s.BasePath); err != nil {
return fmt.Errorf("backup path not accessible: %w", err)
}
return nil
}
// dirSize calculates the total size of a directory
func dirSize(path string) (int64, error) {
var size int64
err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
size += info.Size()
}
return nil
})
return size, err
}