package backup import ( "context" "errors" "fmt" "path/filepath" "sort" "time" "git.metaunix.net/bitgoblin/goblin-store/lib/config" "github.com/rclone/rclone/fs" "github.com/rclone/rclone/fs/operations" "github.com/rclone/rclone/fs/sync" // Register desired backends _ "github.com/rclone/rclone/backend/local" _ "github.com/rclone/rclone/backend/s3" ) func RunIncrementalBackup(ctx context.Context, cfg *config.Config) error { // Configure global Rclone concurrency parameters ci := fs.GetConfig(ctx) ci.Transfers = cfg.Workers ci.Checkers = cfg.Workers * 2 // Setup directories: // - destination/latest: holds current full mirror // - destination/increments/: holds files altered or deleted during sync timestamp := time.Now().Format("2006-01-02_15-04-05") latestDir := filepath.Join(cfg.Destination, "latest") incrementDir := filepath.Join(cfg.Destination, "increments", timestamp) incrementsBase := filepath.Join(cfg.Destination, "increments") // 1. Resolve source and target filesystems fSrc, err := fs.NewFs(ctx, cfg.Source) if err != nil { return fmt.Errorf("failed to open source %q: %w", cfg.Source, err) } fDstLatest, err := fs.NewFs(ctx, latestDir) if err != nil { return fmt.Errorf("failed to open destination %q: %w", latestDir, err) } fDstBackup, err := fs.NewFs(ctx, incrementDir) if err != nil { return fmt.Errorf("failed to initialize increment directory %q: %w", incrementDir, err) } // 2. Set BackupDir on the config context ci.BackupDir = fDstBackup.Root() if cfg.Verbose { fmt.Printf("Starting sync from %s to %s with %d workers...\n", cfg.Source, latestDir, cfg.Workers) } if err := sync.Sync(ctx, fDstLatest, fSrc, false); err != nil { return fmt.Errorf("sync operation failed: %w", err) } // 3. Clean up the timestamp directory if nothing changed during sync entries, err := fDstBackup.List(ctx, "") if err == nil && len(entries) == 0 { _ = operations.TryRmdir(ctx, fDstBackup, "") if cfg.Verbose { fmt.Println("No modifications detected; no increment folder created.") } } else if err == nil { fmt.Printf("Created incremental snapshot: %s\n", incrementDir) } // 4. Prune excess incremental folders if cfg.KeepIncrementals > 0 { if err := pruneIncrementals(ctx, incrementsBase, cfg.KeepIncrementals, cfg.Verbose); err != nil { return fmt.Errorf("retention pruning failed: %w", err) } } return nil } func pruneIncrementals(ctx context.Context, incrementsPath string, keep int, verbose bool) error { fIncrements, err := fs.NewFs(ctx, incrementsPath) if err != nil { return nil } entries, err := fIncrements.List(ctx, "") if err != nil { // If the folder hasn't been created yet, there is nothing to prune if errors.Is(err, fs.ErrorDirNotFound) { return nil } return err } var dirs []string for _, entry := range entries { if _, isDir := entry.(fs.Directory); isDir { dirs = append(dirs, entry.Remote()) } } sort.Strings(dirs) if len(dirs) <= keep { return nil } excess := len(dirs) - keep toDelete := dirs[:excess] if verbose { fmt.Printf("Pruning %d expired incremental snapshots (retention: %d)...\n", excess, keep) } for _, dirName := range toDelete { subDirFs, err := fs.NewFs(ctx, filepath.Join(incrementsPath, dirName)) if err != nil { continue } if err := operations.Purge(ctx, subDirFs, ""); err != nil { return fmt.Errorf("failed to purge %s: %w", dirName, err) } if verbose { fmt.Printf("Deleted snapshot: %s\n", dirName) } } return nil }