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>
170 lines
3.5 KiB
Go
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
|
|
}
|