easy_sync 0.0.1 → 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 -56
  50. data/Guardfile +0 -28
  51. data/lib/easy_sync/rsync.rb +0 -46
  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,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+
6
+ module EasySync
7
+ module Jbod
8
+ # Everything that touches real volumes lives here so it can be faked in tests.
9
+ #
10
+ # Drive identity does not rely on the mount path. At registration a marker
11
+ # file is written to the root of the volume; on every run the mount root is
12
+ # scanned for markers and each registered drive is matched by the serial
13
+ # number inside its marker. A volume that carries no marker, or a marker for
14
+ # a serial the manifest does not know, is never written to.
15
+ class VolumeInfo
16
+ class NotMounted < Error; end
17
+
18
+ Usage = Struct.new(:capacity_bytes, :used_bytes, :free_bytes, keyword_init: true)
19
+
20
+ # +disk+ is what was ejected ("disk4", or the mount point when the
21
+ # physical disk couldn't be resolved). +blocker+ is the process macOS
22
+ # says kept a volume busy ("1234 (/usr/libexec/...)"), when it names one.
23
+ Ejection = Struct.new(:ok, :disk, :message, :blocker, keyword_init: true)
24
+
25
+ attr_reader :mount_root
26
+
27
+ def initialize(mount_root: '/Volumes', shell: Shell.new)
28
+ @mount_root = mount_root
29
+ @shell = shell
30
+ end
31
+
32
+ # Returns MountedDrive structs for every registered drive whose marker is
33
+ # found under +mount_root+. Drives are matched by serial number, never by
34
+ # volume name.
35
+ def mounted_drives(registered)
36
+ by_serial = registered.to_h { |d| [d.serial_number, d] }
37
+ markers.filter_map do |marker|
38
+ drive = by_serial[marker[:serial_number]] or next
39
+ usage = usage(marker[:mount_point])
40
+ MountedDrive.new(drive: drive, mount_point: marker[:mount_point],
41
+ capacity_bytes: usage.capacity_bytes, used_bytes: usage.used_bytes,
42
+ free_bytes: usage.free_bytes)
43
+ end
44
+ end
45
+
46
+ # All markers found under the mount root: [{serial_number:, friendly_name:, mount_point:}]
47
+ def markers
48
+ return [] unless Dir.exist?(mount_root)
49
+
50
+ Dir.children(mount_root).sort.filter_map do |name|
51
+ mount_point = File.join(mount_root, name)
52
+ marker = read_marker(mount_point)
53
+ marker && marker.merge(mount_point: mount_point)
54
+ end
55
+ end
56
+
57
+ # Reads <drive>/.easy_sync/drive.json.
58
+ def read_marker(mount_point)
59
+ path = File.join(mount_point, MARKER_FILE)
60
+ return nil unless File.file?(path)
61
+
62
+ data = JSON.parse(File.read(path), symbolize_names: true)
63
+ return nil unless data[:serial_number]
64
+
65
+ data.slice(:serial_number, :friendly_name, :registered_at)
66
+ rescue JSON::ParserError
67
+ nil
68
+ end
69
+
70
+ def write_marker(mount_point, serial_number:, friendly_name:, registered_at: Time.now.utc.iso8601)
71
+ raise NotMounted, "#{mount_point} is not a directory" unless Dir.exist?(mount_point)
72
+
73
+ path = File.join(mount_point, MARKER_FILE)
74
+ FileUtils.mkdir_p(File.dirname(path))
75
+ File.write(path, JSON.pretty_generate(serial_number: serial_number, friendly_name: friendly_name,
76
+ registered_at: registered_at))
77
+ path
78
+ end
79
+
80
+ # Refreshes <drive>/.easy_sync/ on one mounted drive with a consistent copy
81
+ # of the manifest (via SQLite's online backup API) and of the config file.
82
+ def copy_state(mount_point, manifest:, config_path: nil)
83
+ dir = File.join(mount_point, DRIVE_DIR)
84
+ FileUtils.mkdir_p(dir)
85
+ manifest.backup_to(File.join(dir, 'manifest.sqlite3'))
86
+ FileUtils.cp(config_path, File.join(dir, 'config.yml')) if config_path && File.file?(config_path)
87
+ File.write(File.join(dir, 'README.txt'), <<~TXT)
88
+ This folder is maintained by easy_sync (https://github.com/CarlosEspejo/easy_sync).
89
+ drive.json identifies this drive to the tool; do not edit or delete it.
90
+ manifest.sqlite3 a copy of the manifest (which folder lives on which drive, and the
91
+ deletion history) as of the last sync. Any one drive can rebuild the map.
92
+ config.yml a copy of the configuration used for that sync.
93
+ TXT
94
+ dir
95
+ end
96
+
97
+ # Capacity/used/free in bytes, from `df -kP`.
98
+ def usage(mount_point)
99
+ result = @shell.capture(['df', '-kP', mount_point])
100
+ raise NotMounted, "df failed for #{mount_point}: #{result.output}" unless result.success?
101
+
102
+ fields = result.output.lines.last.to_s.split
103
+ capacity, used, free = fields[1, 3].map { |kb| Integer(kb) * 1024 }
104
+ Usage.new(capacity_bytes: capacity, used_bytes: used, free_bytes: free)
105
+ rescue ArgumentError, TypeError
106
+ raise NotMounted, "could not parse df output for #{mount_point}: #{result&.output}"
107
+ end
108
+
109
+ # APFS Volume UUID from `diskutil info`, or nil when unavailable (non-macOS).
110
+ def volume_uuid(mount_point)
111
+ result = @shell.capture(['diskutil', 'info', mount_point])
112
+ return nil unless result.success?
113
+
114
+ result.output[/Volume UUID:\s*([0-9A-Fa-f-]+)/, 1]
115
+ rescue Errno::ENOENT
116
+ nil
117
+ end
118
+
119
+ # The hardware serial from smartctl, or nil when it can't be determined:
120
+ # smartctl isn't installed, the drive's USB bridge doesn't pass SMART
121
+ # through (common for external enclosures), or the volume's physical disk
122
+ # can't be resolved. Deliberately ignores the process exit status and
123
+ # looks only for a "Serial Number:" line in the output, because smartctl
124
+ # reports "not supported" failures with exit 0 for some device classes
125
+ # and a nonzero exit for others.
126
+ def smartctl_serial(mount_point)
127
+ disk = physical_disk_for(mount_point) or return nil
128
+ @shell.capture(['smartctl', '-a', "/dev/#{disk}"]).output[/^Serial Number:\s*(\S+)/m, 1]
129
+ rescue Errno::ENOENT
130
+ nil
131
+ end
132
+
133
+ # The hardware model from smartctl (manufacturer + model number, e.g.
134
+ # "WDC WD80EFZZ-68BTXN0"), or nil under the same conditions as
135
+ # #smartctl_serial. ATA drives report "Device Model:", NVMe drives
136
+ # "Model Number:"; either is a real make/model, unlike the friendly
137
+ # name or serial, which carry no manufacturer information.
138
+ def smartctl_model(mount_point)
139
+ disk = physical_disk_for(mount_point) or return nil
140
+ self.class.parse_smartctl_model(@shell.capture(['smartctl', '-a', "/dev/#{disk}"]).output)
141
+ rescue Errno::ENOENT
142
+ nil
143
+ end
144
+
145
+ def self.parse_smartctl_model(out)
146
+ out[/^Device Model:\s*(.+?)\s*$/, 1] || out[/^Model Number:\s*(.+?)\s*$/, 1] || out[/^Model Family:\s*(.+?)\s*$/, 1]
147
+ end
148
+
149
+ # SMART health for the drive under +mount_point+. Tries smartctl on the
150
+ # physical disk (plainly, then through a SAT USB bridge), and when that
151
+ # yields nothing falls back to the one-word SMART Status that
152
+ # `diskutil info` reports. Never raises: an enclosure that hides SMART
153
+ # is reported as 'unknown' with the reason, not as an error. Both
154
+ # smartctl forms are tried twice: a drive that reads fine has been seen
155
+ # to fail a single read.
156
+ def smart_health(mount_point)
157
+ disk = physical_disk_for(mount_point)
158
+ if disk
159
+ [[], ['-d', 'sat'], [], ['-d', 'sat']].each do |extra|
160
+ out = @shell.capture(['smartctl', *extra, '-a', "/dev/#{disk}"]).output
161
+ health = self.class.parse_smartctl(out)
162
+ return health if health
163
+ end
164
+ end
165
+ diskutil_health(mount_point)
166
+ rescue Errno::ENOENT
167
+ diskutil_health(mount_point)
168
+ end
169
+
170
+ # Parses `smartctl -a` for both ATA and NVMe drives. Returns nil when the
171
+ # output carries no self-assessment line (device not supported, needs
172
+ # sudo, wrong -d type...), so the caller can try the next source.
173
+ def self.parse_smartctl(out)
174
+ verdict = out[/overall-health self-assessment test result:\s*(\w+)/, 1] or return nil
175
+ counters = {}
176
+ out.scan(/^\s*\d+\s+(Reallocated_Sector_Ct|Current_Pending_Sector|Offline_Uncorrectable|Reallocated_Event_Count)\s+\S+\s+\d+\s+\d+\s+\d+\s+\S+\s+\S+\s+\S+\s+(\d+)/) do |name, raw|
177
+ counters[name] = raw.to_i
178
+ end
179
+ critical = out[/^Critical Warning:\s*(0x[0-9a-fA-F]+)/, 1]
180
+ media_errors = out[/^Media and Data Integrity Errors:\s*(\d+)/, 1]&.to_i
181
+ pct_used = out[/^Percentage Used:\s*(\d+)%/, 1]
182
+ temp = out[/^\s*\d+\s+Temperature_Celsius\s+\S+\s+\d+\s+\d+\s+\d+\s+\S+\s+\S+\s+\S+\s+(\d+)/, 1] ||
183
+ out[/^Temperature:\s*(\d+)\s*Celsius/, 1]
184
+ power_on_hours = out[/^\s*\d+\s+Power_On_Hours\s+\S+\s+\d+\s+\d+\s+\d+\s+\S+\s+\S+\s+\S+\s+(\d+)/, 1] ||
185
+ out[/^Power On Hours:\s*([\d,]+)/, 1]&.delete(',')
186
+
187
+ reallocated = counters['Reallocated_Sector_Ct']
188
+ other_bad = counters.values_at('Current_Pending_Sector', 'Offline_Uncorrectable').compact.sum
189
+ other_bad += media_errors.to_i
190
+ other_bad += 1 if critical && critical.hex != 0
191
+ bad = other_bad + reallocated.to_i
192
+
193
+ status = if verdict.casecmp?('PASSED') then bad.positive? ? 'warning' : 'ok'
194
+ else 'failing'
195
+ end
196
+ parts = [verdict.upcase]
197
+ parts << "reallocated #{counters['Reallocated_Sector_Ct']}" if counters.key?('Reallocated_Sector_Ct')
198
+ parts << "pending #{counters['Current_Pending_Sector']}" if counters.key?('Current_Pending_Sector')
199
+ parts << "uncorrectable #{counters['Offline_Uncorrectable']}" if counters.key?('Offline_Uncorrectable')
200
+ parts << "critical warning #{critical}" if critical && critical.hex != 0
201
+ parts << "media errors #{media_errors}" if media_errors
202
+ parts << "#{pct_used}% of rated life used" if pct_used
203
+ parts << "#{temp}°C" if temp
204
+ Health.new(status: status, detail: parts.join(' · '), source: 'smartctl',
205
+ power_on_hours: power_on_hours&.to_i, reallocated_sector_ct: reallocated,
206
+ other_bad: other_bad.positive?)
207
+ end
208
+
209
+ def diskutil_health(mount_point)
210
+ result = @shell.capture(['diskutil', 'info', mount_point])
211
+ word = result.success? ? result.output[/SMART Status:\s*(.+)$/, 1]&.strip : nil
212
+ case word
213
+ when 'Verified' then Health.new(status: 'ok', detail: 'diskutil reports Verified (no counters available)', source: 'diskutil')
214
+ when 'Failing' then Health.new(status: 'failing', detail: 'diskutil reports Failing', source: 'diskutil')
215
+ else Health.new(status: 'unknown', detail: 'SMART not exposed by this enclosure (smartctl and diskutil both blind)',
216
+ source: 'none')
217
+ end
218
+ rescue Errno::ENOENT
219
+ Health.new(status: 'unknown', detail: 'diskutil/smartctl not available on this system', source: 'none')
220
+ end
221
+
222
+ # Resolves a mounted volume to the physical (or physical store) disk
223
+ # underneath it: mount point -> APFS container ("Part of Whole") ->
224
+ # container's physical store. Returns nil if any step can't be read.
225
+ def physical_disk_for(mount_point)
226
+ info = @shell.capture(['diskutil', 'info', mount_point])
227
+ return nil unless info.success?
228
+
229
+ container = info.output[/Part of Whole:\s*(disk\d+)/, 1] or return nil
230
+ container_info = @shell.capture(['diskutil', 'info', container])
231
+ return nil unless container_info.success?
232
+
233
+ container_info.output[/APFS Physical Store:\s*(disk\d+s\d+)/, 1] || container
234
+ end
235
+
236
+ # Ejects the whole physical disk under a mounted drive, as Finder's Eject
237
+ # does, so the enclosure can be powered off. Never forces: a volume some
238
+ # process still has open (Spotlight, Backblaze) stays mounted and the
239
+ # result names that process.
240
+ def eject(mount_point)
241
+ disk = physical_disk_for(mount_point).to_s[/\Adisk\d+/] || mount_point
242
+ result = @shell.capture(['diskutil', 'eject', disk])
243
+ lines = result.output.lines.map(&:strip).reject(&:empty?)
244
+ Ejection.new(ok: result.success?, disk: disk, message: lines.last.to_s,
245
+ blocker: result.output[/dissented by PID (\d+(?: \([^)]*\))?)/, 1])
246
+ end
247
+
248
+ # Best-effort FileVault lock state for a registered drive that isn't
249
+ # currently mounted, looked up by name in `diskutil apfs list` (a locked
250
+ # volume has no mount point but still appears there by name). Returns
251
+ # true (locked), false (present and unlocked, or not encrypted), or nil
252
+ # when the drive can't be found there at all (unplugged, or diskutil
253
+ # failed) - macOS only.
254
+ def locked?(friendly_name)
255
+ result = @shell.capture(['diskutil', 'apfs', 'list'])
256
+ return nil unless result.success?
257
+
258
+ match = apfs_volumes(result.output).find { |v| v[:name].casecmp?(friendly_name) }
259
+ match && match[:lock_state] == 'Locked'
260
+ rescue Errno::ENOENT
261
+ nil
262
+ end
263
+
264
+ # Parses `diskutil apfs list` into [{name:, lock_state:}, ...]. lock_state
265
+ # is "Locked", "Unlocked", or nil (not FileVault-encrypted). The Name and
266
+ # FileVault lines belong to the same volume block but are a few lines
267
+ # apart, so this pairs each Name with the next FileVault line after it.
268
+ def apfs_volumes(output)
269
+ volumes = []
270
+ name = nil
271
+ output.each_line do |line|
272
+ if (m = line.match(/^\s*Name:\s*(.+?)\s*\(Case-insensitive\)\s*$/))
273
+ name = m[1]
274
+ elsif name && (m = line.match(/^\s*FileVault:\s*(?:No|Yes \((Locked|Unlocked)\))/))
275
+ volumes << { name: name, lock_state: m[1] }
276
+ name = nil
277
+ end
278
+ end
279
+ volumes
280
+ end
281
+ end
282
+ end
283
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'jbod/models'
4
+ require_relative 'jbod/manifest'
5
+ require_relative 'jbod/placement'
6
+ require_relative 'jbod/share_scan'
7
+ require_relative 'jbod/volume_info'
8
+ require_relative 'jbod/mirror'
9
+ require_relative 'jbod/tripwire'
10
+ require_relative 'jbod/restorer'
11
+ require_relative 'jbod/purger'
12
+ require_relative 'jbod/cleaner'
13
+ require_relative 'jbod/run_lock'
14
+ require_relative 'jbod/keep_awake'
15
+ require_relative 'jbod/planner'
16
+ require_relative 'jbod/run_log'
17
+ require_relative 'jbod/sync_eta'
18
+ require_relative 'jbod/backblaze'
19
+ require_relative 'jbod/dashboard'
20
+ require_relative 'jbod/page_cache'
21
+ require_relative 'jbod/scrubber'
22
+ require_relative 'jbod/scrub_pool'
23
+ require_relative 'jbod/benchmarker'
24
+ require_relative 'jbod/runner'
25
+
26
+ module EasySync
27
+ # Folder-level mirroring from a NAS share onto independently mounted drives.
28
+ module Jbod
29
+ # Everything easy_sync keeps on a drive lives in this folder at its root:
30
+ # the identity marker, and after every run a copy of the manifest and
31
+ # config so any single surviving drive can rebuild the map.
32
+ DRIVE_DIR = '.easy_sync'
33
+ MARKER_FILE = File.join(DRIVE_DIR, 'drive.json')
34
+ end
35
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module EasySync
6
+ # Runs external commands. Streams output to +out+ as it arrives and returns
7
+ # the full output together with the exit status. Everything that shells out
8
+ # goes through here so tests can inject a fake.
9
+ class Shell
10
+ Result = Struct.new(:output, :status, keyword_init: true) do
11
+ def success? = status.zero?
12
+ end
13
+
14
+ def initialize(out: $stdout)
15
+ @out = out
16
+ end
17
+
18
+ # The same shell, echoing to a different destination (a run log tee).
19
+ def with_out(out) = self.class.new(out: out)
20
+
21
+ # Runs +argv+ (an Array, never a shell string) and streams its output.
22
+ #
23
+ # The child runs in its own process group. On Interrupt that whole group
24
+ # (rsync and its helper processes) gets SIGINT, then SIGTERM if it lingers,
25
+ # whether the interrupt came from a terminal Ctrl-C or from a signal sent
26
+ # to our pid alone (pkill, launchd, an app quitting); without this we would
27
+ # sit blocked until rsync finished the folder it was on.
28
+ def run(argv, echo: true)
29
+ lines = []
30
+ status = nil
31
+ Open3.popen2e(*argv, pgroup: true) do |stdin, stdout_err, wait|
32
+ stdin.close
33
+ begin
34
+ stdout_err.each_line do |line|
35
+ lines << line
36
+ @out.puts line.chomp if echo
37
+ end
38
+ status = wait.value.exitstatus
39
+ rescue Interrupt
40
+ stop_child(wait)
41
+ raise
42
+ end
43
+ end
44
+ Result.new(output: lines.join, status: status)
45
+ end
46
+
47
+ def stop_child(wait)
48
+ Process.kill('INT', -wait.pid) # negative pid: the whole process group
49
+ return if wait.join(5)
50
+
51
+ Process.kill('TERM', -wait.pid)
52
+ wait.join(5)
53
+ rescue Errno::ESRCH
54
+ nil # already gone
55
+ end
56
+
57
+ # Runs +argv+ quietly and returns the captured output.
58
+ def capture(argv)
59
+ run(argv, echo: false)
60
+ end
61
+ end
62
+ end