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,615 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module EasySync
6
+ module Jbod
7
+ # One manual sync run: discover folders on the NAS shares, place new ones,
8
+ # mirror every folder to its assigned drive, then regenerate the dashboard.
9
+ class Runner
10
+ class SourceUnavailable < Error; end
11
+
12
+ # A configured NAS share.
13
+ Source = Struct.new(:path, keyword_init: true) do
14
+ def name = File.basename(path)
15
+
16
+ def self.from_config(entry)
17
+ new(path: entry.is_a?(Hash) ? entry[:path] : entry.to_s)
18
+ end
19
+ end
20
+
21
+ # A placement unit as seen on the NAS. +key+ is its manifest folder_path:
22
+ # "tv/Show Name" for a subfolder, or the share name ("synology") with
23
+ # +root_only+ for the share's loose top-level files (see
24
+ # docs/fine-placement.md).
25
+ SourceFolder = Struct.new(:key, :path, :root_only, keyword_init: true) do
26
+ def share = key.split('/').first
27
+ end
28
+
29
+ # +trips+ is every Tripwire::Trip found this run, enforced or not;
30
+ # +tripped+ the folders it actually held back, and +run_tripped+ whether
31
+ # it stopped the whole run.
32
+ Report = Struct.new(:placed, :synced, :failed, :drive_full, :skipped, :unplaced, :missing_on_source, :warnings,
33
+ :purged, :would_purge, :pending, :unhealthy, :empty, :refetched, :check_failed, :trips,
34
+ :tripped, :run_tripped, keyword_init: true) do
35
+ def initialize(**)
36
+ super
37
+ (members - %i[pending run_tripped]).each { |m| self[m] ||= [] }
38
+ self.pending ||= 0
39
+ self.run_tripped ||= false
40
+ end
41
+ end
42
+
43
+ attr_reader :settings, :manifest
44
+
45
+ # The status a SMART reading is recorded (and shown) as: a 'warning'
46
+ # caused solely by a reallocated count no higher than the drive's
47
+ # verified baseline is 'degraded_stable'. Any other reason for 'warning'
48
+ # (pending or uncorrectable sectors, media errors, an NVMe critical
49
+ # flag) is left alone. `status --smart` uses this for its live readings.
50
+ def self.alert_status(manifest, serial_number, health)
51
+ return health.status unless health.status == 'warning' && !health.other_bad
52
+
53
+ baseline = manifest.reallocated_baseline(serial_number)
54
+ baseline && health.reallocated_sector_ct.to_i > baseline ? 'warning' : 'degraded_stable'
55
+ end
56
+
57
+ # +settings+ is Config#jbod. +sizer+ returns the byte size of a source folder.
58
+ # +accept_changes+ lets this run's tripwire trips through: true for all
59
+ # of them, or an array of folder keys (see docs/tripwire.md).
60
+ def initialize(settings, manifest:, volume_info: nil, mirror: nil, dashboard: nil, purger: nil,
61
+ shell: Shell.new, out: $stdout, clock: Time, sizer: nil, dry_run: false, purge: nil,
62
+ accept_changes: nil)
63
+ @settings = settings
64
+ @manifest = manifest
65
+ @shell = shell
66
+ @out = out
67
+ @clock = clock
68
+ @dry_run = dry_run
69
+ @purge = purge.nil? ? settings.fetch(:purge, true) : purge
70
+ @volume_info = volume_info || VolumeInfo.new(mount_root: settings[:mount_root], shell: shell)
71
+ @mirror = mirror || Mirror.new(shell: shell, excludes: settings.fetch(:exclude_folders, []),
72
+ extra_args: settings.fetch(:rsync_args, []) + (dry_run ? ['--dry-run'] : []))
73
+ @dashboard = dashboard || Dashboard.new(manifest, grace_days: settings[:grace_days],
74
+ scrub_stale_days: settings.fetch(:scrub_stale_days, 30), clock: clock,
75
+ mount_root: settings[:mount_root])
76
+ @purger = purger || Purger.new(manifest, grace_days: settings[:grace_days], grace_runs: settings[:grace_runs],
77
+ clock: clock, out: out)
78
+ @sizer = sizer || method(:du_bytes)
79
+ @tripwire = Tripwire.from_settings(settings)
80
+ @accept_changes = accept_changes
81
+ end
82
+
83
+ def sources
84
+ Array(settings[:sources]).map { |e| Source.from_config(e) }
85
+ end
86
+
87
+ def run
88
+ report = Report.new
89
+ @run_started_at = @clock.now.utc.iso8601
90
+ folders, available = source_folders(report)
91
+ mounted = refresh_drives(report)
92
+ copy_state_to_drives(mounted, report) # at the start too, so an interrupted run still leaves a copy
93
+ by_serial = mounted.to_h { |m| [m.serial_number, m] }
94
+ # Free space as placements are made during this run, so two new folders
95
+ # are not both sent to the drive that was emptiest at the start.
96
+ # Seeded from whichever is smaller of live `df` free space and
97
+ # (capacity minus what the manifest already promised that drive,
98
+ # synced or not): a folder placed by an earlier run whose copy
99
+ # phase hasn't reached this drive yet doesn't show up in `df`, so
100
+ # trusting `df` alone lets a later run keep stacking new folders on
101
+ # top of commitments it can't see. Found on the real fleet:
102
+ # backup-06-8tb ended up promised 8.55 TB against a 7.28 TiB drive
103
+ # this way, after an interrupted run's placements were invisible to
104
+ # the next run's capacity check.
105
+ free_ledger = mounted.to_h do |m|
106
+ promised = manifest.folders_on(m.serial_number).sum { |f| f.size_bytes.to_i }
107
+ committed_free = m.capacity_bytes.to_i - promised
108
+ [m.serial_number, [m.free_bytes.to_i, committed_free].min]
109
+ end
110
+ new_folders = folders.count { |f| manifest.folder(f.key).nil? }
111
+ @measured = 0
112
+ @placed_this_run = Hash.new { |h, k| h[k] = [] }
113
+ @synced_before = []
114
+ if new_folders.positive?
115
+ @out.puts "#{new_folders} new folder#{'s' if new_folders != 1} to measure and place " \
116
+ '(du over the network can take a while per folder)'
117
+ end
118
+
119
+ # Phase 1: decide where everything goes, before any copying, so the
120
+ # full picture (including what does NOT fit) exists even if the long
121
+ # copy phase is interrupted.
122
+ plan = []
123
+ inventory = []
124
+ folders.each do |folder|
125
+ record = manifest.folder(folder.key)
126
+ if record.nil?
127
+ target, size, state, detail = place(folder, mounted, free_ledger, report)
128
+ inventory << { folder_path: folder.key, size_bytes: size, state: state, detail: detail }
129
+ plan << [folder, target] if target
130
+ else
131
+ inventory << { folder_path: folder.key, size_bytes: record.size_bytes, state: 'placed',
132
+ detail: "on #{drive_name(record.drive_serial)}" }
133
+ target = by_serial[record.drive_serial]
134
+ if target.nil?
135
+ drive = manifest.drive(record.drive_serial)
136
+ warn(report, "#{folder.key}: its drive #{drive&.friendly_name || record.drive_serial} is not mounted, skipping")
137
+ manifest.mark_folder_status(folder.key, 'skipped_unmounted') unless @dry_run
138
+ report.skipped << folder.key
139
+ next
140
+ end
141
+ @synced_before << folder.key if record.last_synced_at
142
+ plan << [folder, target]
143
+ end
144
+ end
145
+ manifest.replace_source_inventory(inventory) unless @dry_run
146
+ unplaced_bytes = inventory.sum { |i| i[:state] == 'unplaced' ? i[:size_bytes].to_i : 0 }
147
+ @out.puts "\nPlan: #{plan.size} folder#{'s' if plan.size != 1} to sync (#{report.placed.size} newly placed), " \
148
+ "#{report.unplaced.size} not backed up (#{Placement.format_bytes(unplaced_bytes)}: no room), " \
149
+ "#{report.empty.size} empty on the NAS"
150
+
151
+ # Folders that vanished from a mounted share are flagged now, before
152
+ # the copy phase, so an interrupted run still notices them.
153
+ source_status = reconcile_manifest(folders, available, report)
154
+
155
+ # Phase 1b: before anything is copied, check every folder already on
156
+ # the drives for how many of its files the copy would overwrite or
157
+ # has lost on the NAS, and stop what looks like ransomware.
158
+ plan, checks = check_folders(plan, report)
159
+ plan = apply_tripwire(plan, checks, report)
160
+
161
+ # Phase 2: copy. Deletions come last, and never after a stopped run:
162
+ # ransomware that deletes could otherwise ride out the grace period.
163
+ plan.each { |folder, target| sync_folder(folder, target, report, checks[folder.key]) }
164
+ report.pending = manifest.pending_deletions.size
165
+ purge(mounted, report) unless report.run_tripped
166
+
167
+ if @dry_run
168
+ @out.puts "\nDRY RUN: nothing was copied, recorded, or deleted, and the dashboard was left as it was."
169
+ else
170
+ mounted = refresh_drives(report, quiet: true)
171
+ copy_state_to_drives(mounted, report)
172
+ path = @dashboard.write(settings[:dashboard_path], mounted: mounted, source_status: source_status)
173
+ @out.puts "\nDashboard written to #{path}"
174
+ end
175
+ summarize(report)
176
+ report
177
+ end
178
+
179
+ # Folders found across the configured shares, plus the names of the shares
180
+ # that were actually available. A share whose mount point is missing or
181
+ # empty (a stale mount point left behind by macOS looks exactly like that)
182
+ # is treated as unavailable so a --delete mirror never runs against it.
183
+ def source_folders(report = Report.new)
184
+ raise SourceUnavailable, 'no sources configured (set :jbod: :sources: in the config)' if sources.empty?
185
+
186
+ folders = []
187
+ available = []
188
+ sources.each do |source|
189
+ unless Dir.exist?(source.path) && !Dir.empty?(source.path)
190
+ warn(report, "source #{source.path} is not mounted (or is empty), skipping")
191
+ next
192
+ end
193
+ available << source.name
194
+ folders.concat(units_of(source))
195
+ end
196
+ raise SourceUnavailable, 'none of the configured sources are mounted' if available.empty?
197
+
198
+ [folders, available]
199
+ end
200
+
201
+ private
202
+
203
+ # One unit per top-level subfolder, plus a root unit for the share's
204
+ # loose top-level files. An already-placed root unit is kept even once
205
+ # its files are gone, so their removal is noticed.
206
+ def units_of(source)
207
+ units = subfolders(source.path).map do |name|
208
+ SourceFolder.new(key: File.join(source.name, name), path: File.join(source.path, name))
209
+ end
210
+ placed_root = manifest.folder(source.name)
211
+ units << SourceFolder.new(key: source.name, path: source.path, root_only: true) if placed_root || loose_files(source.path).any?
212
+ units
213
+ end
214
+
215
+ def loose_files(path) = ShareScan.loose_files(path, settings[:exclude_folders])
216
+ def subfolders(path) = ShareScan.subfolders(path, settings[:exclude_folders])
217
+
218
+ def refresh_drives(report, quiet: false)
219
+ mounted = @volume_info.mounted_drives(manifest.drives)
220
+ mounted.each do |m|
221
+ unless @dry_run
222
+ manifest.update_drive_usage(m.serial_number, used_bytes: m.used_bytes, free_bytes: m.free_bytes,
223
+ capacity_bytes: m.capacity_bytes)
224
+ backfill_model(m) if m.drive.model.nil?
225
+ end
226
+ if m.mount_point != File.join(settings[:mount_root], m.friendly_name) && !quiet
227
+ warn(report, "#{m.friendly_name} is mounted at #{m.mount_point} (matched by serial, not by name)")
228
+ end
229
+ check_health(m, report) unless quiet
230
+ end
231
+ unless quiet
232
+ mounted_serials = mounted.map(&:serial_number)
233
+ manifest.drives.reject { |d| mounted_serials.include?(d.serial_number) }.each do |d|
234
+ warn(report, unmounted_message(d))
235
+ end
236
+ end
237
+ mounted
238
+ end
239
+
240
+ # Drives registered before the model field existed (or whose enclosure
241
+ # didn't expose smartctl at registration time) get it filled in here,
242
+ # once per drive: after the first success the drive's model is no
243
+ # longer nil, so this stops asking smartctl on every subsequent run.
244
+ def backfill_model(mounted_drive)
245
+ model = @volume_info.smartctl_model(mounted_drive.mount_point)
246
+ manifest.update_drive_model(mounted_drive.serial_number, model: model) if model
247
+ end
248
+
249
+ # Reads SMART once per run for each mounted drive and records it. A
250
+ # drive that is starting to fail is the one thing worth shouting about -
251
+ # but only when a bad counter is actually growing. A reallocated-sector
252
+ # count that's nonzero but unchanged since the last verified checkpoint
253
+ # (or since it was first seen) is old, stable damage, not an active
254
+ # failure in progress, and gets the quieter 'degraded_stable' status
255
+ # instead of nagging on every run.
256
+ #
257
+ # A drive whose SMART was read before but comes back 'unknown' this run
258
+ # keeps its last reading: one failed read (seen on a ThunderBay bay that
259
+ # read fine 20 minutes earlier and again right after) must not turn a
260
+ # known status into 'n/a', which would also hide a failing drive.
261
+ def check_health(mounted_drive, report)
262
+ health = @volume_info.smart_health(mounted_drive.mount_point) or return
263
+ previous = manifest.drive(mounted_drive.serial_number)
264
+ if health.status == 'unknown' && previous&.smart_status && previous.smart_status != 'unknown'
265
+ warn(report, "could not read SMART on #{mounted_drive.friendly_name} this run (#{health.detail}); " \
266
+ "keeping its last reading (#{previous.smart_status}, #{previous.smart_checked_at})")
267
+ return alert_unhealthy(mounted_drive, previous.smart_status, previous.smart_detail, report)
268
+ end
269
+
270
+ status = health.status
271
+ unless @dry_run
272
+ if health.reallocated_sector_ct
273
+ manifest.record_smart_check(mounted_drive.serial_number, reallocated_sector_ct: health.reallocated_sector_ct)
274
+ end
275
+ status = resolve_alert_status(mounted_drive.serial_number, health)
276
+ manifest.update_drive_health(mounted_drive.serial_number, status: status, detail: health.detail,
277
+ power_on_hours: health.power_on_hours)
278
+ end
279
+ alert_unhealthy(mounted_drive, status, health.detail, report)
280
+ end
281
+
282
+ def alert_unhealthy(mounted_drive, status, detail, report)
283
+ return if %w[ok unknown degraded_stable].include?(status)
284
+
285
+ report.unhealthy << [mounted_drive.friendly_name, status]
286
+ verb = status == 'failing' ? 'is FAILING' : 'is starting to fail'
287
+ warn(report, "drive #{mounted_drive.friendly_name} #{verb}: SMART says #{detail}. " \
288
+ "Plan to replace it: register a new drive, then " \
289
+ "`easy_sync replace-drive #{mounted_drive.friendly_name} --to NEW_NAME --copy`.")
290
+ end
291
+
292
+ def resolve_alert_status(serial_number, health)
293
+ self.class.alert_status(manifest, serial_number, health)
294
+ end
295
+
296
+ # Best-effort: says *why* a drive isn't mounted when it's detectably a
297
+ # locked FileVault volume rather than just absent, so "not mounted" isn't
298
+ # the only signal you get when you forgot to unlock a drive.
299
+ def unmounted_message(drive)
300
+ base = "drive #{drive.friendly_name} (#{drive.serial_number}) is not mounted"
301
+ case @volume_info.locked?(drive.friendly_name)
302
+ when true then "#{base}: it's connected but still locked. Unlock it in Finder or with " \
303
+ "`diskutil apfs unlockVolume #{drive.friendly_name}` and sync again."
304
+ else base
305
+ end
306
+ end
307
+
308
+ # Returns [target, size, state, detail]; target is nil when not placed.
309
+ def place(folder, mounted, free_ledger, report)
310
+ if folder.root_only ? loose_files(folder.path).empty? : empty_source?(folder.path)
311
+ warn(report, "#{folder.key} has no files on the NAS (only excluded or hidden ones); not placing it")
312
+ report.empty << folder.key
313
+ return [nil, 0, 'empty', 'no real files on the NAS']
314
+ end
315
+ @measured += 1
316
+ @out.puts " measuring #{folder.key} (new folder #{@measured})..."
317
+ size = folder.root_only ? loose_bytes(folder.path) : @sizer.call(folder.path)
318
+ candidates = mounted.map { |m| m.dup.tap { |c| c.free_bytes = free_ledger[c.serial_number] } }
319
+ prefer = share_drives(folder.share)
320
+ target = Placement.choose(candidates, size_bytes: size, reserve_bytes: settings.fetch(:reserve_bytes, 0),
321
+ prefer: prefer)
322
+ why = prefer.include?(target.serial_number) ? "with the rest of #{folder.share}" : 'most free space'
323
+ if @dry_run
324
+ @out.puts "Would place new folder #{folder.key} (#{Placement.format_bytes(size)}) on #{target.friendly_name}"
325
+ else
326
+ manifest.assign_folder(folder.key, target.serial_number, size_bytes: size,
327
+ scope: folder.root_only ? 'root' : 'tree',
328
+ note: "new folder, #{why} (#{Placement.format_bytes(target.free_bytes)} free)")
329
+ @out.puts "Placing new folder #{folder.key} (#{Placement.format_bytes(size)}) on #{target.friendly_name}"
330
+ end
331
+ report.placed << [folder.key, target.friendly_name]
332
+ @placed_this_run[folder.share] |= [target.serial_number]
333
+ free_ledger[target.serial_number] -= size.to_i
334
+ [mounted.find { |m| m.serial_number == target.serial_number }, size, 'placed', "on #{target.friendly_name}"]
335
+ rescue Placement::NoMountedDrives, Placement::DoesNotFit => e
336
+ warn(report, "cannot place #{folder.key}: #{e.message}")
337
+ report.unplaced << folder.key
338
+ [nil, size, 'unplaced', e.is_a?(Placement::NoMountedDrives) ? 'no drive mounted' : 'no drive has room']
339
+ end
340
+
341
+ # Runs the read-only check probe for every planned folder copied before
342
+ # (a folder placed this run has nothing on its drive to lose). Returns
343
+ # the plan minus folders whose check failed (nothing vouches for them,
344
+ # and over SMB the copy would almost always fail too), and
345
+ # { folder key => Mirror::Check }.
346
+ def check_folders(plan, report)
347
+ to_check = plan.select { |folder, _| @synced_before.include?(folder.key) }
348
+ return [plan, {}] if to_check.empty?
349
+
350
+ @out.puts "\nChecking #{count(to_check.size)} folder#{'s' if to_check.size != 1} already on the drives " \
351
+ 'for files the copy would overwrite or that are gone from the NAS...'
352
+ started = @clock.now
353
+ checks = {}
354
+ to_check.each do |folder, target|
355
+ check = @mirror.check(folder.path, File.join(target.mount_point, folder.key), root_only: folder.root_only ? true : false)
356
+ if check.nil?
357
+ warn(report, "#{folder.key}: the pre-copy check failed, so it is not copied this run")
358
+ manifest.mark_folder_status(folder.key, 'skipped_check_failed') unless @dry_run
359
+ report.check_failed << folder.key
360
+ next
361
+ end
362
+ check.known = manifest.pending_deletions(folder_path: folder.key).map(&:relative_path)
363
+ checks[folder.key] = check
364
+ next unless check.changed.positive?
365
+
366
+ @out.puts " #{folder.key}: #{check.replaced.size} replaced, #{check.missing_files.size} gone from the NAS " \
367
+ "(of #{count(check.files_on_drive)} on the drive)"
368
+ end
369
+ total = checks.sum { |_, c| c.changed }
370
+ @out.puts "Checked in #{(@clock.now - started).round}s: #{count(total)} existing file#{'s' if total != 1} " \
371
+ "would change (tripwire: run #{count(@tripwire.run_files)}, folder #{@tripwire.folder_files} " \
372
+ "and #{(@tripwire.folder_ratio * 100).round}%#{', off' unless @tripwire.enabled?}" \
373
+ "#{', report only' unless enforce_tripwire?})"
374
+ [plan.reject { |folder, _| report.check_failed.include?(folder.key) }, checks]
375
+ end
376
+
377
+ def enforce_tripwire? = settings.fetch(:tripwire_enforce, false)
378
+
379
+ # Returns the plan left to copy: all of it, the folders that did not
380
+ # trip, or nothing when the whole run tripped. A report-only tripwire
381
+ # says what it would have done and returns the plan unchanged.
382
+ def apply_tripwire(plan, checks, report)
383
+ unknown = @accept_changes.is_a?(Array) ? @accept_changes - plan.map { |f, _| f.key } : []
384
+ warn(report, "--accept-changes: #{unknown.join(', ')} #{unknown.size == 1 ? 'is' : 'are'} not in this sync") unless unknown.empty?
385
+ decision = @tripwire.decide(checks, accept: @accept_changes)
386
+ report.trips = decision.trips
387
+ return plan if decision.trips.empty?
388
+
389
+ enforce = enforce_tripwire?
390
+ describe_trips(decision, enforce)
391
+ unless enforce
392
+ @out.puts 'The tripwire is report-only (tripwire_enforce: false), so this run copies everything anyway.'
393
+ return plan
394
+ end
395
+
396
+ manifest.record_trips(@run_started_at, decision.trips) unless @dry_run
397
+ if decision.run_tripped
398
+ report.run_tripped = true
399
+ report.tripped = plan.map { |folder, _| folder.key }
400
+ warn(report, "SYNC #{@dry_run ? 'WOULD STOP' : 'STOPPED'}: #{count(decision.run_changed)} existing files on the drives " \
401
+ "would be overwritten or are gone from the NAS (the limit is #{count(@tripwire.run_files)}). " \
402
+ "Nothing #{@dry_run ? 'would be' : 'was'} copied or purged. Check the NAS for ransomware first; " \
403
+ 'if you made this change yourself, run `easy_sync sync --accept-changes`.')
404
+ else
405
+ report.tripped = decision.blocked
406
+ report.tripped.each do |key|
407
+ warn(report, "#{key} #{@dry_run ? 'would be' : 'was'} held back by the tripwire and not copied; if you made " \
408
+ "this change yourself, run `easy_sync sync --accept-changes #{key}`")
409
+ end
410
+ end
411
+ report.tripped.each { |key| manifest.mark_folder_status(key, 'tripped') } unless @dry_run
412
+ plan.reject { |folder, _| report.tripped.include?(folder.key) }
413
+ end
414
+
415
+ def describe_trips(decision, enforce)
416
+ verb = !enforce ? 'would have tripped (report only)' : @dry_run ? 'would trip' : 'tripped'
417
+ @out.puts "\n------------------ tripwire #{verb} ------------------"
418
+ decision.trips.each do |t|
419
+ why = t.scope == 'folder' ? 'this folder alone' : 'counts towards the run total'
420
+ @out.puts " #{t.folder_path}: #{t.replaced} replaced, #{t.missing} gone from the NAS, of #{count(t.files_on_drive)} " \
421
+ "on the drive (#{why})#{' - accepted' if t.accepted}"
422
+ t.samples.each { |path| @out.puts " #{path}" }
423
+ end
424
+ end
425
+
426
+ def sync_folder(folder, target, report, check = nil)
427
+ destination = File.join(target.mount_point, folder.key)
428
+ @out.puts "\n------------------ #{folder.key} -> #{target.friendly_name} ------------------"
429
+ started = @clock.now.utc.iso8601
430
+ result = @mirror.sync(folder.path, destination, root_only: folder.root_only ? true : false)
431
+ unless @dry_run
432
+ manifest.record_sync(folder_path: folder.key, drive_serial: target.serial_number, started_at: started,
433
+ finished_at: @clock.now.utc.iso8601, exit_status: result.exit_status,
434
+ bytes_transferred: result.bytes_transferred, total_size_bytes: result.total_size_bytes,
435
+ run_started_at: @run_started_at)
436
+ end
437
+ if result.success?
438
+ report.synced << folder.key
439
+ # Only a clean copy starts deletion clocks, from its pre-copy
440
+ # check. A folder copied for the first time had nothing to check.
441
+ if check && @dry_run
442
+ @out.puts " #{folder.key}: #{check.missing.size} file#{'s' if check.missing.size != 1} gone from the NAS would be recorded" unless check.missing.empty?
443
+ elsif check
444
+ note_missing(folder, check.missing)
445
+ end
446
+ refetch_flagged(folder, target, destination, report)
447
+ elsif result.disk_full?
448
+ manifest.mark_folder_status(folder.key, 'drive_full') unless @dry_run
449
+ warn(report, "#{folder.key} did not fully sync: #{target.friendly_name} is full " \
450
+ "(#{Placement.format_bytes(target.free_bytes)} free before this run). " \
451
+ "Move it to a drive with more room: `easy_sync reassign #{folder.key} DRIVE_NAME`, then sync.")
452
+ report.drive_full << folder.key
453
+ else
454
+ warn(report, "rsync for #{folder.key} exited with status #{result.exit_status}")
455
+ report.failed << folder.key
456
+ end
457
+ end
458
+
459
+ # Re-copies files `scrub` flagged as corrupt/unreadable, overwriting the
460
+ # bad copy on the drive - the only cost a folder with no flagged files
461
+ # pays for this. Only called after the copy pass has already succeeded;
462
+ # a folder skipped for any reason leaves its flags exactly as they are,
463
+ # so the next sync tries again.
464
+ def refetch_flagged(folder, target, destination, report)
465
+ # A flagged file since deleted on the NAS can't be refetched (and
466
+ # would make rsync fail the whole list with exit 23); the deletion
467
+ # probe already tracks it for Purger, and the next scrub drops its row.
468
+ flagged = manifest.flagged_checksums(target.serial_number, folder.key)
469
+ .select { |f| File.exist?(File.join(folder.path, f.relative_path)) }
470
+ return if flagged.empty?
471
+
472
+ if @dry_run
473
+ @out.puts " #{folder.key}: #{flagged.size} flagged file#{'s' if flagged.size != 1} would be refetched"
474
+ return
475
+ end
476
+
477
+ result = @mirror.refetch(folder.path, destination, flagged.map(&:relative_path))
478
+ if result.success?
479
+ manifest.mark_refetched(target.serial_number, folder.key, flagged.map(&:relative_path))
480
+ report.refetched << [folder.key, flagged.size]
481
+ @out.puts " #{folder.key}: refetched #{flagged.size} file#{'s' if flagged.size != 1} flagged by scrub"
482
+ else
483
+ warn(report, "#{folder.key}: refetch of #{flagged.size} scrub-flagged file#{'s' if flagged.size != 1} failed " \
484
+ "(rsync exit #{result.status}); will try again next sync")
485
+ end
486
+ end
487
+
488
+ # Flags manifest folders that were not seen this run. A folder whose share
489
+ # was not mounted is only "unavailable", not missing.
490
+ def reconcile_manifest(folders, available, report)
491
+ status = folders.to_h { |f| [f.key, :present] }
492
+ manifest.folders.each do |f|
493
+ next if status.key?(f.folder_path)
494
+
495
+ share = f.folder_path.split('/').first
496
+ if available.include?(share)
497
+ status[f.folder_path] = :missing
498
+ report.missing_on_source << f.folder_path
499
+ manifest.reconcile_pending(f.folder_path, [['', 'folder']]) unless @dry_run
500
+ warn(report, "#{f.folder_path} is in the manifest but no longer on the NAS (still on #{drive_name(f.drive_serial)}); " \
501
+ "it will be deleted from the drive #{settings[:grace_days]} days after it first went missing")
502
+ else
503
+ status[f.folder_path] = :source_unavailable
504
+ manifest.mark_folder_status(f.folder_path, 'skipped_source_unmounted') unless @dry_run
505
+ report.skipped << f.folder_path
506
+ end
507
+ end
508
+ status
509
+ end
510
+
511
+ # Feeds rsync's "would delete" report into the pending_deletions table.
512
+ def note_missing(folder, extraneous)
513
+ return if @dry_run
514
+
515
+ counts = manifest.reconcile_pending(folder.key, extraneous)
516
+ return if counts.values.all?(&:zero?)
517
+
518
+ @out.puts " #{folder.key}: #{counts[:new]} newly missing on NAS, #{counts[:still]} still missing, " \
519
+ "#{counts[:reappeared]} reappeared"
520
+ end
521
+
522
+ def purge(mounted, report)
523
+ return unless @purge
524
+
525
+ expired = manifest.expired_deletions(now: @clock.now, grace_days: settings[:grace_days],
526
+ grace_runs: settings[:grace_runs])
527
+ return if expired.empty?
528
+
529
+ @out.puts "\n------------------ purging #{expired.size} expired deletion#{'s' if expired.size != 1} ------------------"
530
+ result = @purger.run(mounted, dry_run: @dry_run)
531
+ report.purged = result.purged.map { |p, drive| [p.folder_path, p.relative_path, drive] }
532
+ report.would_purge = result.would_purge.map { |p, drive| [p.folder_path, p.relative_path, drive] }
533
+ result.skipped.each do |p, why|
534
+ label = p.whole_folder? ? "#{p.folder_path} (whole folder)" : "#{p.folder_path}/#{p.relative_path}"
535
+ warn(report, "not purging #{label}: #{why}")
536
+ end
537
+ end
538
+
539
+ # Every mounted drive gets a fresh copy of the manifest and config in
540
+ # its .easy_sync folder, so losing the Mac never loses the map.
541
+ def copy_state_to_drives(mounted, report)
542
+ return if @dry_run
543
+
544
+ mounted.each do |m|
545
+ @volume_info.copy_state(m.mount_point, manifest: manifest, config_path: settings[:config_path])
546
+ rescue SystemCallError, SQLite3::Exception => e
547
+ warn(report, "could not copy the manifest to #{m.friendly_name}: #{e.message}")
548
+ end
549
+ end
550
+
551
+ # True when a folder holds no regular file apart from excluded/hidden
552
+ # names (a show folder left with just a .DS_Store, an empty downloads
553
+ # dir). Stops at the first real file, so a full folder costs one stat.
554
+ def empty_source?(path)
555
+ excluded = Array(settings[:exclude_folders])
556
+ Dir.each_child(path) do |name|
557
+ next if name.start_with?('.') || excluded.any? { |pat| File.fnmatch?(pat, name) }
558
+
559
+ child = File.join(path, name)
560
+ return false if File.file?(child)
561
+ return false if File.directory?(child) && !empty_source?(child)
562
+ end
563
+ true
564
+ rescue SystemCallError
565
+ false # can't tell; let rsync decide
566
+ end
567
+
568
+ # Drives already holding part of +share+, so its new units join them
569
+ # while they fit (Placement prefers these). Includes this run's own
570
+ # placements, which a dry run never writes to the manifest.
571
+ def share_drives(share)
572
+ placed = manifest.folders.select { |f| f.share == share }.map(&:drive_serial)
573
+ (placed + @placed_this_run[share]).uniq
574
+ end
575
+
576
+ def loose_bytes(path)
577
+ loose_files(path).sum { |n| File.size(File.join(path, n)) }
578
+ end
579
+
580
+ def du_bytes(path)
581
+ result = @shell.capture(['du', '-sk', path])
582
+ raise Error, "du failed for #{path}: #{result.output}" unless result.success?
583
+
584
+ Integer(result.output.split.first) * 1024
585
+ end
586
+
587
+ def count(n) = n.to_i.to_s.reverse.scan(/\d{1,3}/).join(',').reverse
588
+
589
+ def drive_name(serial)
590
+ manifest.drive(serial)&.friendly_name || serial
591
+ end
592
+
593
+ def warn(report, message)
594
+ report.warnings << message
595
+ @out.puts "WARNING: #{message}"
596
+ end
597
+
598
+ def summarize(report)
599
+ @out.puts "Synced #{report.synced.size}, placed #{report.placed.size} new, failed #{report.failed.size}, " \
600
+ "drive full #{report.drive_full.size}, skipped #{report.skipped.size}, " \
601
+ "unplaced #{report.unplaced.size}, empty #{report.empty.size}, purged #{report.purged.size}, " \
602
+ "#{report.pending.to_i} pending deletion#{'s' if report.pending.to_i != 1}, warnings #{report.warnings.size}"
603
+ unless report.tripped.empty? && report.check_failed.empty?
604
+ @out.puts "Held back by the tripwire #{report.tripped.size}#{' (the whole run)' if report.run_tripped}, " \
605
+ "check failed #{report.check_failed.size}"
606
+ end
607
+ refetched_files = report.refetched.sum { |_, n| n }
608
+ return unless refetched_files.positive?
609
+
610
+ @out.puts "Refetched #{refetched_files} file#{'s' if refetched_files != 1} flagged by scrub, " \
611
+ "across #{report.refetched.size} folder#{'s' if report.refetched.size != 1}"
612
+ end
613
+ end
614
+ end
615
+ end