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 }