easy_sync 0.0.5 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +5 -5
  2. data/.gitignore +8 -0
  3. data/.rspec +2 -0
  4. data/.ruby-version +1 -0
  5. data/CHANGELOG.md +85 -0
  6. data/CLAUDE.md +133 -0
  7. data/Gemfile +2 -0
  8. data/README.md +607 -21
  9. data/Rakefile +6 -8
  10. data/bin/easy_sync +8 -1
  11. data/docs/fine-placement.md +174 -0
  12. data/docs/hardware-notes.md +71 -0
  13. data/docs/integrity-scan.md +435 -0
  14. data/docs/parallel-scrub.md +280 -0
  15. data/docs/performance.md +146 -0
  16. data/docs/tripwire.md +232 -0
  17. data/easy_sync.gemspec +25 -16
  18. data/lib/easy_sync/cli.rb +1246 -0
  19. data/lib/easy_sync/config.rb +202 -0
  20. data/lib/easy_sync/error.rb +5 -0
  21. data/lib/easy_sync/jbod/backblaze.rb +113 -0
  22. data/lib/easy_sync/jbod/benchmarker.rb +149 -0
  23. data/lib/easy_sync/jbod/cleaner.rb +93 -0
  24. data/lib/easy_sync/jbod/dashboard.rb +611 -0
  25. data/lib/easy_sync/jbod/keep_awake.rb +33 -0
  26. data/lib/easy_sync/jbod/manifest.rb +1068 -0
  27. data/lib/easy_sync/jbod/mirror.rb +204 -0
  28. data/lib/easy_sync/jbod/models.rb +165 -0
  29. data/lib/easy_sync/jbod/page_cache.rb +56 -0
  30. data/lib/easy_sync/jbod/placement.rb +71 -0
  31. data/lib/easy_sync/jbod/planner.rb +76 -0
  32. data/lib/easy_sync/jbod/purger.rb +161 -0
  33. data/lib/easy_sync/jbod/restorer.rb +142 -0
  34. data/lib/easy_sync/jbod/run_lock.rb +98 -0
  35. data/lib/easy_sync/jbod/run_log.rb +57 -0
  36. data/lib/easy_sync/jbod/runner.rb +615 -0
  37. data/lib/easy_sync/jbod/scrub_pool.rb +136 -0
  38. data/lib/easy_sync/jbod/scrubber.rb +321 -0
  39. data/lib/easy_sync/jbod/share_scan.rb +26 -0
  40. data/lib/easy_sync/jbod/sync_eta.rb +68 -0
  41. data/lib/easy_sync/jbod/tripwire.rb +80 -0
  42. data/lib/easy_sync/jbod/volume_info.rb +283 -0
  43. data/lib/easy_sync/jbod.rb +35 -0
  44. data/lib/easy_sync/shell.rb +62 -0
  45. data/lib/easy_sync/templates/dashboard.html.erb +419 -0
  46. data/lib/easy_sync/version.rb +3 -1
  47. data/lib/easy_sync.rb +7 -3
  48. metadata +84 -45
  49. data/Gemfile.lock +0 -55
  50. data/Guardfile +0 -28
  51. data/lib/easy_sync/rsync.rb +0 -60
  52. data/lib/easy_sync/sync_logger.rb +0 -20
  53. data/lib/easy_sync/sync_runner.rb +0 -44
  54. data/spec/easy_sync/rsync_spec.rb +0 -25
  55. data/spec/easy_sync/sync_runner_spec.rb +0 -54
  56. data/spec/spec_helper.rb +0 -33
  57. data/spec/support.rb +0 -89
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module EasySync
6
+ module Jbod
7
+ # Removes expired deletion candidates from the drives. This is the only
8
+ # code in the tool that deletes backup data, so it is deliberately narrow:
9
+ # a candidate is removed only if its folder's drive is mounted right now,
10
+ # the path resolves inside that folder's destination, and it has been
11
+ # missing on the NAS for both the day and run thresholds.
12
+ class Purger
13
+ Result = Struct.new(:purged, :would_purge, :skipped, keyword_init: true) do
14
+ def initialize(**)
15
+ super
16
+ members.each { |m| self[m] ||= [] }
17
+ end
18
+ end
19
+
20
+ def initialize(manifest, grace_days:, grace_runs:, clock: Time, out: $stdout)
21
+ @manifest = manifest
22
+ @grace_days = grace_days
23
+ @grace_runs = grace_runs
24
+ @clock = clock
25
+ @out = out
26
+ end
27
+
28
+ # +mounted+ are the MountedDrive structs for this run.
29
+ def run(mounted, dry_run: false)
30
+ by_serial = mounted.to_h { |m| [m.serial_number, m] }
31
+ result = Result.new
32
+ expired = @manifest.expired_deletions(now: @clock.now, grace_days: @grace_days, grace_runs: @grace_runs)
33
+ .select { |p| ready?(p) }
34
+
35
+ # Whole folders first; their file-level candidates go with them.
36
+ folders, paths = expired.partition(&:whole_folder?)
37
+ folders.each do |pending|
38
+ root = @manifest.folder(pending.folder_path)&.root?
39
+ purge_one(pending, by_serial, result, dry_run: dry_run, root: root) do |dest, drive|
40
+ # A root unit is only a share's loose top-level files; the
41
+ # share's subfolders below dest belong to other folders.
42
+ root ? remove_top_level_files(dest) : FileUtils.rm_rf(dest)
43
+ remove_empty_dirs(root ? dest : File.dirname(dest), drive.mount_point)
44
+ # A 'reassigned' candidate is the OLD copy of a folder that still
45
+ # has a valid manifest row elsewhere (see #ready?); only the
46
+ # files come out, the folder record itself must survive. A
47
+ # 'missing_on_nas' candidate means the folder is gone entirely.
48
+ unless pending.reassigned?
49
+ @manifest.clear_pending(pending.folder_path)
50
+ @manifest.remove_folder(pending.folder_path, note: "deleted from #{drive.friendly_name}: " \
51
+ "missing on NAS since #{pending.first_missing_at}")
52
+ end
53
+ end
54
+ end
55
+
56
+ gone = folders.map(&:folder_path)
57
+ files, dirs = paths.reject { |p| gone.include?(p.folder_path) }.partition { |p| p.kind == 'file' }
58
+ files.each do |pending|
59
+ purge_one(pending, by_serial, result, dry_run: dry_run) { |dest, _| File.delete(dest) if File.exist?(dest) }
60
+ end
61
+ # Deepest directories first so parents empty out before we reach them.
62
+ dirs.sort_by { |p| -p.relative_path.count('/') }.each do |pending|
63
+ purge_one(pending, by_serial, result, dry_run: dry_run) do |dest, _|
64
+ if Dir.exist?(dest) && !Dir.empty?(dest)
65
+ @out.puts " leaving #{dest}: not empty yet"
66
+ next false
67
+ end
68
+ Dir.rmdir(dest) if Dir.exist?(dest)
69
+ end
70
+ end
71
+ result
72
+ end
73
+
74
+ private
75
+
76
+ # A folder moved off a drive (cause 'reassigned') is only safe to purge
77
+ # from there once it has actually landed, verified, on its new drive:
78
+ # grace_days alone exists to protect against a flaky NAS probe and says
79
+ # nothing about whether the fresh copy elsewhere actually succeeded.
80
+ def ready?(pending)
81
+ return true unless pending.reassigned?
82
+
83
+ current = @manifest.folder(pending.folder_path)
84
+ !current.nil? && current.drive_serial != pending.drive_serial && current.last_sync_status == 'ok'
85
+ end
86
+
87
+ def purge_one(pending, by_serial, result, dry_run:, root: false)
88
+ drive = by_serial[pending.drive_serial]
89
+ unless drive
90
+ result.skipped << [pending, 'drive not mounted']
91
+ return
92
+ end
93
+ if pending.whole_folder? && !root && (live = overlapping_live_folder(pending, drive.serial_number))
94
+ result.skipped << [pending, "#{live} is a live folder that overlaps it on #{drive.friendly_name}; " \
95
+ 'not deleting (resolve by hand)']
96
+ return
97
+ end
98
+
99
+ base = File.join(drive.mount_point, pending.folder_path)
100
+ dest = pending.whole_folder? ? base : File.join(base, pending.relative_path)
101
+ unless inside?(dest, base) && File.expand_path(base).start_with?("#{File.expand_path(drive.mount_point)}/")
102
+ result.skipped << [pending, 'path escapes its folder']
103
+ return
104
+ end
105
+
106
+ label = pending.whole_folder? ? "#{pending.folder_path} (whole folder)" : "#{pending.folder_path}/#{pending.relative_path}"
107
+ if dry_run
108
+ @out.puts " would delete #{label} from #{drive.friendly_name}"
109
+ result.would_purge << [pending, drive.friendly_name]
110
+ return
111
+ end
112
+
113
+ @out.puts " deleting #{label} from #{drive.friendly_name}"
114
+ return if yield(dest, drive) == false
115
+
116
+ @manifest.record_deletion(pending, drive_serial: drive.serial_number)
117
+ result.purged << [pending, drive.friendly_name]
118
+ end
119
+
120
+ # Another folder placed on the same drive that deleting this whole
121
+ # folder would take with it (it lives below the path), or whose tree
122
+ # this path is part of. Either means the manifest changed shape after
123
+ # the candidate was recorded, and an rm_rf would destroy live data.
124
+ def overlapping_live_folder(pending, serial)
125
+ prefix = "#{pending.folder_path}/"
126
+ @manifest.folders_on(serial).find do |f|
127
+ next false if f.folder_path == pending.folder_path
128
+
129
+ f.folder_path.start_with?(prefix) || (!f.root? && pending.folder_path.start_with?("#{f.folder_path}/"))
130
+ end&.folder_path
131
+ end
132
+
133
+ # The share directory a removed folder sat in (and the root unit's own
134
+ # directory) is removed once nothing is left in it, so a share that
135
+ # has left a drive entirely leaves no empty shell behind. Never goes
136
+ # above the share level, and never touches the drive root.
137
+ def remove_empty_dirs(dir, mount_point)
138
+ top = File.expand_path(mount_point)
139
+ dir = File.expand_path(dir)
140
+ while dir.start_with?("#{top}/") && Dir.exist?(dir) && Dir.empty?(dir)
141
+ Dir.rmdir(dir)
142
+ dir = File.dirname(dir)
143
+ end
144
+ end
145
+
146
+ def remove_top_level_files(dir)
147
+ return unless Dir.exist?(dir)
148
+
149
+ Dir.children(dir).each do |name|
150
+ path = File.join(dir, name)
151
+ File.delete(path) if File.file?(path) || File.symlink?(path)
152
+ end
153
+ end
154
+
155
+ def inside?(path, base)
156
+ expanded = File.expand_path(path)
157
+ expanded == File.expand_path(base) || expanded.start_with?("#{File.expand_path(base)}/")
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module EasySync
6
+ module Jbod
7
+ # `easy_sync restore`: the reverse of `sync`. Copies a folder (or every
8
+ # folder under a share, or everything) from wherever the manifest says it
9
+ # currently lives back onto its NAS share. For rebuilding a NAS after a
10
+ # wipe/reformat, the scenario the old single-drive drobo-sync restore
11
+ # scripts covered by hand; here the folders for one share can be spread
12
+ # across several drives, so this walks the manifest instead of assuming
13
+ # one source directory.
14
+ #
15
+ # Never deletes, exactly like those old scripts: only adds and updates
16
+ # files on the NAS. A drive with fewer files than the NAS already has
17
+ # leaves the extra NAS files alone.
18
+ class Restorer
19
+ class UnknownTarget < Error; end
20
+
21
+ Result = Struct.new(:restored, :skipped, :failed, keyword_init: true) do
22
+ def initialize(**)
23
+ super
24
+ members.each { |m| self[m] ||= [] }
25
+ end
26
+ end
27
+
28
+ def initialize(settings, manifest:, shell: Shell.new, out: $stdout)
29
+ @manifest = manifest
30
+ @shell = shell
31
+ @out = out
32
+ @excludes = Array(settings[:exclude_folders]).map { |e| "--exclude=#{e}" }
33
+ @share_paths = Array(settings[:sources]).to_h do |e|
34
+ path = e.is_a?(Hash) ? e[:path] : e.to_s
35
+ [File.basename(path), path]
36
+ end
37
+ end
38
+
39
+ # +targets+ are folder_paths ("tv/Breaking Bad") or share names ("tv"),
40
+ # expanded to every folder currently in the manifest under that share
41
+ # (including the share's root-files unit, which is keyed by its name).
42
+ # Raises if a name matches nothing placed.
43
+ def resolve(targets)
44
+ all = @manifest.folders
45
+ targets.flat_map do |t|
46
+ exact = all.find { |f| f.folder_path == t }
47
+ next [exact] if exact && !exact.root?
48
+
49
+ under_share = [exact].compact + all.select { |f| f.folder_path.start_with?("#{t}/") }
50
+ raise UnknownTarget, "#{t} matches no placed folder or share" if under_share.empty?
51
+
52
+ under_share
53
+ end.uniq(&:folder_path)
54
+ end
55
+
56
+ # +folders+ are Folder structs from the manifest; +mounted+ are this
57
+ # run's MountedDrive structs. Returns a Result.
58
+ def run(folders, mounted, dry_run: false)
59
+ result = Result.new
60
+ by_serial = mounted.to_h { |m| [m.serial_number, m] }
61
+ folders.each { |folder| restore_one(folder, by_serial, result, dry_run: dry_run) }
62
+ result
63
+ end
64
+
65
+ private
66
+
67
+ # A rotted file must not quietly go back to the NAS: warn and name
68
+ # every file `scrub` has flagged for this folder on its current drive,
69
+ # whatever its state (awaiting refetch, refetched, or unresolved).
70
+ def warn_about_flagged_files(folder, drive)
71
+ flagged = @manifest.scrub_findings_for(drive.serial_number, folder.folder_path)
72
+ return if flagged.empty?
73
+
74
+ @out.puts "WARNING: #{folder.folder_path}: #{flagged.size} file#{'s' if flagged.size != 1} on #{drive.friendly_name} " \
75
+ "#{flagged.size == 1 ? 'is' : 'are'} flagged by scrub (#{flagged.map { |f| "#{f.relative_path}: #{f.status}" }.join(', ')}); " \
76
+ 'restoring may copy a rotted file onto the NAS.'
77
+ end
78
+
79
+ def restore_one(folder, by_serial, result, dry_run:)
80
+ drive = by_serial[folder.drive_serial]
81
+ if drive.nil?
82
+ name = @manifest.drive(folder.drive_serial)&.friendly_name || folder.drive_serial
83
+ return skip(result, folder.folder_path, "its drive #{name} is not mounted")
84
+ end
85
+
86
+ root = share_root(folder.folder_path)
87
+ if root.nil?
88
+ share = folder.folder_path.split('/').first
89
+ return skip(result, folder.folder_path, "#{share} is not a configured source; `add-source` it first")
90
+ end
91
+ return skip(result, folder.folder_path, "the NAS share is not mounted at #{root}") unless Dir.exist?(root)
92
+
93
+ destination = nas_destination(folder.folder_path)
94
+
95
+ source = File.join(drive.mount_point, folder.folder_path)
96
+ return skip(result, folder.folder_path, "nothing at #{source} on #{drive.friendly_name}") unless Dir.exist?(source)
97
+
98
+ warn_about_flagged_files(folder, drive)
99
+ @out.puts "\n------------------ #{folder.folder_path}: #{drive.friendly_name} -> NAS ------------------"
100
+ rsync_result = restore_copy(source, destination, dry_run: dry_run, root_only: folder.root?)
101
+ if rsync_result.success?
102
+ result.restored << folder.folder_path
103
+ else
104
+ @out.puts "WARNING: rsync for #{folder.folder_path} exited with status #{rsync_result.status}"
105
+ result.failed << folder.folder_path
106
+ end
107
+ end
108
+
109
+ # No --delete, ever: restoring must never remove anything already on the NAS.
110
+ # A root-files unit restores only its top-level files; the share's
111
+ # subfolders are folders of their own, possibly on other drives.
112
+ def restore_copy(source, destination, dry_run:, root_only: false)
113
+ FileUtils.mkdir_p(File.dirname(destination)) unless dry_run
114
+ argv = ['rsync', '-a', '--partial', '--stats', '--info=progress2', '--itemize-changes', *@excludes]
115
+ argv << Mirror::ROOT_ONLY if root_only
116
+ argv << '--dry-run' if dry_run
117
+ argv += [with_slash(source), with_slash(destination)]
118
+ @shell.run(argv)
119
+ end
120
+
121
+ def share_root(folder_path)
122
+ @share_paths[folder_path.split('/').first]
123
+ end
124
+
125
+ def nas_destination(folder_path)
126
+ share, rest = folder_path.split('/', 2)
127
+ root = @share_paths[share] or return nil
128
+
129
+ rest ? File.join(root, rest) : root
130
+ end
131
+
132
+ def skip(result, folder_path, why)
133
+ @out.puts "WARNING: skipping #{folder_path}: #{why}"
134
+ result.skipped << folder_path
135
+ end
136
+
137
+ def with_slash(path)
138
+ path.end_with?('/') ? path : "#{path}/"
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module EasySync
6
+ module Jbod
7
+ # Prevents two long-running commands (sync, scrub, clean, restore) from
8
+ # overlapping: doubled NAS/drive load, and two processes racing over the
9
+ # same state. A PID file at +path+ is the lock; a stale one (its process
10
+ # no longer running) is reclaimed automatically rather than blocking
11
+ # forever. The file's second line names which command holds it, so
12
+ # `status`/the dashboard can say "Scrub running" instead of assuming sync;
13
+ # a lock file with no second line (written before this existed) reads as
14
+ # 'sync', its original and only kind. Every line from the third on is free
15
+ # text the holder can update as it goes (Jbod::ScrubPool uses one line per
16
+ # drive its workers are currently on) without disturbing the mtime that
17
+ # #status reads as the run's start time.
18
+ class RunLock
19
+ class AlreadyRunning < Error; end
20
+
21
+ # pid: the process holding the lock. started_at: the lock file's mtime,
22
+ # which is set once when it's written and never touched again for the
23
+ # life of the run, so it doubles as the run's start time. kind: 'sync',
24
+ # 'scrub', 'clean', or 'restore'. current: an Array of the holder's
25
+ # free-form progress notes (see #note), empty if it hasn't set any.
26
+ Status = Struct.new(:pid, :started_at, :kind, :current, keyword_init: true)
27
+
28
+ def initialize(path)
29
+ @path = path
30
+ end
31
+
32
+ # Runs the block while holding the lock. Raises AlreadyRunning instead of
33
+ # running the block if another live process already holds it.
34
+ def acquire(kind: 'sync')
35
+ if (pid = holder)
36
+ raise AlreadyRunning, "another easy_sync run is already running (pid #{pid}). " \
37
+ "If that's wrong (the process really is gone), remove #{@path}."
38
+ end
39
+
40
+ FileUtils.mkdir_p(File.dirname(@path))
41
+ File.write(@path, "#{Process.pid}\n#{kind}\n")
42
+ begin
43
+ yield
44
+ ensure
45
+ File.delete(@path) if File.exist?(@path) && pid_in_file == Process.pid
46
+ end
47
+ end
48
+
49
+ # Replaces the free-form lines from the third on (one per +names+, empty
50
+ # to clear them) without touching the file's mtime (the run's recorded
51
+ # start time) or releasing the lock. A no-op unless this process is the
52
+ # one actually holding it.
53
+ def note(*names)
54
+ return unless File.exist?(@path) && pid_in_file == Process.pid
55
+
56
+ mtime = File.mtime(@path)
57
+ File.write(@path, "#{[Process.pid, kind_in_file, *names].join("\n")}\n")
58
+ File.utime(mtime, mtime, @path)
59
+ end
60
+
61
+ # The run currently holding the lock, or nil if none is (a stale lock
62
+ # left by a dead process counts as none).
63
+ def status
64
+ pid = holder or return nil
65
+
66
+ Status.new(pid: pid, started_at: File.mtime(@path), kind: kind_in_file, current: current_in_file)
67
+ end
68
+
69
+ private
70
+
71
+ # The PID holding the lock, or nil if there is none or it's stale.
72
+ def holder
73
+ pid = pid_in_file
74
+ return nil if pid.nil? || pid.zero?
75
+
76
+ Process.kill(0, pid)
77
+ pid
78
+ rescue Errno::ESRCH
79
+ nil # no such process: the lock is stale
80
+ end
81
+
82
+ def pid_in_file
83
+ return nil unless File.exist?(@path)
84
+
85
+ File.read(@path).lines.first.to_s.strip.to_i
86
+ end
87
+
88
+ def kind_in_file
89
+ line = File.read(@path).lines[1]
90
+ line ? line.strip : 'sync'
91
+ end
92
+
93
+ def current_in_file
94
+ File.read(@path).lines.drop(2).map(&:strip).reject(&:empty?)
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'time'
5
+
6
+ module EasySync
7
+ module Jbod
8
+ # Tees everything a sync prints into ~/.easy_sync/logs/sync-<timestamp>.log
9
+ # as it happens, so a multi-day run has a record even if the terminal is
10
+ # gone. rsync's in-place progress chunks (carriage-return updates) are kept
11
+ # off disk; the per-folder --stats block that follows them has the totals.
12
+ class RunLog
13
+ attr_reader :path
14
+
15
+ # +prefix+ names the run kind ('sync' or 'scrub'), so each is pruned
16
+ # separately: scrub-*.log files never push out sync-*.log files, or the
17
+ # other way around.
18
+ def self.open(dir, keep: 20, out: $stdout, clock: Time, prefix: 'sync')
19
+ FileUtils.mkdir_p(dir)
20
+ path = File.join(dir, "#{prefix}-#{clock.now.strftime('%Y%m%d-%H%M%S')}.log")
21
+ prune(dir, keep: keep - 1, prefix: prefix)
22
+ new(File.open(path, 'a'), out: out, path: path)
23
+ end
24
+
25
+ # Keeps the newest +keep+ logs matching +prefix+ in +dir+.
26
+ def self.prune(dir, keep:, prefix: 'sync')
27
+ logs = Dir.glob(File.join(dir, "#{prefix}-*.log")).sort
28
+ (logs.size - [keep, 0].max).clamp(0, logs.size).times { |i| File.delete(logs[i]) }
29
+ end
30
+
31
+ def initialize(file, out:, path: nil)
32
+ @file = file
33
+ @out = out
34
+ @path = path
35
+ @file.sync = true
36
+ @mutex = Mutex.new # Jbod::ScrubPool's workers all write through the same RunLog
37
+ end
38
+
39
+ def puts(*lines)
40
+ @mutex.synchronize do
41
+ @out.puts(*lines)
42
+ lines = [''] if lines.empty?
43
+ lines.flatten.each { |l| @file.puts(l) unless l.to_s.include?("\r") }
44
+ end
45
+ end
46
+
47
+ def print(*args)
48
+ @mutex.synchronize do
49
+ @out.print(*args)
50
+ @file.print(*args) unless args.join.include?("\r")
51
+ end
52
+ end
53
+
54
+ def close = @file.close
55
+ end
56
+ end
57
+ end