package backup import ( "context" "fmt" "os" "os/exec" "path/filepath" "sort" "time" "git.metaunix.net/bitgoblin/goblin-store/lib/config" ) // RunRsyncIncremental executes a local hardlink-based incremental backup. func RunRsyncIncremental(ctx context.Context, cfg *config.Config) error { // Verify that the rsync binary exists in PATH if _, err := exec.LookPath("rsync"); err != nil { return fmt.Errorf("rsync binary not found in PATH: %w", err) } // 1. Ensure the base destination directory exists if err := os.MkdirAll(cfg.Destination, 0755); err != nil { return fmt.Errorf("failed to create destination directory %q: %w", cfg.Destination, err) } // 2. Identify the most recent snapshot for link-dest deduplication latestSnapshot, err := findLatestSnapshot(cfg.Destination) if err != nil { return fmt.Errorf("failed scanning for previous snapshots: %w", err) } timestamp := time.Now().Format("2006-01-02_15-04-05") currentBackupDir := filepath.Join(cfg.Destination, timestamp) // Ensure the source ends with a trailing slash so rsync copies directory // contents directly rather than nesting the parent folder src := filepath.Clean(cfg.Source) + string(filepath.Separator) // 3. Assemble rsync arguments args := []string{ "-a", // Archive mode: preserves permissions, symlinks, timestamps, etc. "--delete", // Delete files in dest that no longer exist in source } if cfg.Verbose { args = append(args, "-v") } if latestSnapshot != "" { // Use absolute path for --link-dest to prevent path resolution issues absLinkDest, err := filepath.Abs(latestSnapshot) if err != nil { return fmt.Errorf("failed resolving link-dest path: %w", err) } args = append(args, fmt.Sprintf("--link-dest=%s", absLinkDest)) if cfg.Verbose { fmt.Printf("Linking unchanged files against baseline: %s\n", absLinkDest) } } else if cfg.Verbose { fmt.Println("No previous snapshots found. Generating initial full baseline...") } args = append(args, src, currentBackupDir) if cfg.Verbose { fmt.Printf("Executing: rsync %s\n", args) } // 4. Run the rsync command with context cancellation cmd := exec.CommandContext(ctx, "rsync", args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return fmt.Errorf("rsync execution failed: %w", err) } fmt.Printf("Successfully created snapshot at: %s\n", currentBackupDir) // 5. Enforce retention policy if cfg.KeepIncrementals > 0 { if err := pruneSnapshots(cfg.Destination, cfg.KeepIncrementals, cfg.Verbose); err != nil { return fmt.Errorf("retention pruning failed: %w", err) } } return nil } // findLatestSnapshot scans the destination directory and returns the path // to the newest directory matching the timestamp pattern. func findLatestSnapshot(destRoot string) (string, error) { entries, err := os.ReadDir(destRoot) if err != nil { return "", err } var snapshots []string for _, entry := range entries { if entry.IsDir() { snapshots = append(snapshots, entry.Name()) } } if len(snapshots) == 0 { return "", nil } // Lexicographical sort works chronologically because of "YYYY-MM-DD_HH-MM-SS" format sort.Strings(snapshots) return filepath.Join(destRoot, snapshots[len(snapshots)-1]), nil } // pruneSnapshots deletes the oldest snapshots when the total count exceeds the retention limit. func pruneSnapshots(destRoot string, keep int, verbose bool) error { entries, err := os.ReadDir(destRoot) if err != nil { return err } var snapshots []string for _, entry := range entries { if entry.IsDir() { snapshots = append(snapshots, entry.Name()) } } sort.Strings(snapshots) if len(snapshots) <= keep { return nil } excessCount := len(snapshots) - keep toDelete := snapshots[:excessCount] if verbose { fmt.Printf("Pruning %d snapshot(s) exceeding retention limit (%d)...\n", excessCount, keep) } for _, dirName := range toDelete { targetPath := filepath.Join(destRoot, dirName) if verbose { fmt.Printf("Deleting expired snapshot: %s\n", targetPath) } // os.RemoveAll removes directory entries and unlinks hardlinked inodes safely if err := os.RemoveAll(targetPath); err != nil { return fmt.Errorf("failed to remove snapshot %s: %w", targetPath, err) } } return nil }