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
data/lib/easy_sync/cli.rb CHANGED
@@ -0,0 +1,1246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'time'
5
+
6
+ module EasySync
7
+ # Command-line entry point.
8
+ class CLI
9
+ # [group, [[command, description], ...]] in the order a new user meets them.
10
+ COMMAND_GROUPS = [
11
+ ['Set up, once', [
12
+ ['add-source PATH', 'add a NAS share (mounted on this Mac); each of its folders is placed on its own'],
13
+ ['register-drive MOUNT_POINT [--name NAME] [--serial SERIAL]', 'add a backup drive (mounted and unlocked)'],
14
+ ['plan [SHARE ...] [--largest-drive SIZE]', 'measure each share (or just the ones named) and check every folder fits a drive']
15
+ ]],
16
+ ['Back up', [
17
+ ['sync [--dry-run] [--no-purge] [--no-keep-awake] [--accept-changes [FOLDER ...]]',
18
+ 'mirror the shares onto the drives; --accept-changes lets through a bulk change the tripwire stopped (this run only)'],
19
+ ['eject [NAME ...] [--dry-run] [--yes]', 'eject every connected backup drive (or just the ones named) so the enclosure can be ' \
20
+ "powered off; asks first if Backblaze hasn't finished uploading one (--yes: don't ask)"],
21
+ ['status [--all] [--smart]', 'whether a sync is running, drives, their health, and a folder summary; --all lists every folder; ' \
22
+ '--smart reads SMART from the mounted drives now instead of showing the last sync\'s reading'],
23
+ ['dashboard', 'regenerate the HTML report']
24
+ ]],
25
+ ['Maintain', [
26
+ ['pending', 'deletion candidates and when each expires'],
27
+ ['clean [--dry-run]', 'remove excluded junk (#recycle, .DS_Store, ...) from the drives now'],
28
+ ['scrub [NAME ...] [--all] [--jobs N] [--for DURATION] [--dry-run] [--no-keep-awake]',
29
+ 'read every tracked file back off a drive and check it against its baseline; catches bit rot rsync cannot see'],
30
+ ['benchmark [NAME ...] [--all] [--size SIZE] [--history]',
31
+ "time a drive's sequential write and read against its own earlier runs; a slowing drive can be failing"],
32
+ ['history [FOLDER]', 'where a folder has lived'],
33
+ ['reassign FOLDER|SHARE DRIVE_NAME [--copy] [--note TEXT] [--force]',
34
+ 'move a folder (or every folder of a share) to another drive; --copy copies it drive-to-drive now instead of the next sync pulling it from the NAS'],
35
+ ['rename-drive OLD_NAME NEW_NAME', "relabel a drive, or swap two drives' names; touches no data"],
36
+ ['replace-drive OLD_NAME [--to NEW_NAME] [--copy]', 'retire a drive; hand its folders to NEW, or let the next sync re-place them'],
37
+ ['forget-drive NAME [...] [--dry-run]', 'delete a retired drive and all its history from the manifest (for test drives); touches no data'],
38
+ ['verify-drive NAME [--note TEXT]', 'record that a full-surface scan (SpinRite etc.) found no new defects; resets the reallocated-sector baseline'],
39
+ ['restore FOLDER|SHARE [...] | --all [--dry-run]', 'copy folders back onto the NAS from wherever they live (reverse of sync)'],
40
+ ['remove-source PATH', 'stop backing up a share (drives are left alone)'],
41
+ ['sources', 'list the configured shares']
42
+ ]]
43
+ ].freeze
44
+
45
+ USAGE = begin
46
+ width = COMMAND_GROUPS.flat_map { |_, cmds| cmds.map { |c, _| c.length } }.max + 2
47
+ lines = ['Usage: easy_sync [--config PATH] <command> [options]', '']
48
+ COMMAND_GROUPS.each do |group, cmds|
49
+ lines << "#{group}:"
50
+ cmds.each { |c, d| lines << " #{c.ljust(width)}#{d}" }
51
+ lines << ''
52
+ end
53
+ lines << 'Global options:'
54
+ lines << " #{'--config PATH'.ljust(width)}use this config file (default ~/.easy_sync/config.yml; EASY_SYNC_CONFIG does the same)"
55
+ lines << " #{'--version'.ljust(width)}print the version"
56
+ lines << " #{'--help'.ljust(width)}this text"
57
+ "#{lines.join("\n")}\n"
58
+ end
59
+
60
+ def initialize(argv, out: $stdout, err: $stderr, config_path: nil, shell: Shell.new, env: ENV,
61
+ keep_awake: Jbod::KeepAwake.new, clock: Time, stdin: $stdin)
62
+ @argv = argv.dup
63
+ @stdin = stdin
64
+ @out = out
65
+ @err = err
66
+ @shell = shell
67
+ @keep_awake = keep_awake
68
+ @clock = clock
69
+ @config_path = config_path || env['EASY_SYNC_CONFIG'] || Config.default_path
70
+ end
71
+
72
+ def run
73
+ parse_global_options!
74
+ command = @argv.shift
75
+ case command
76
+ when nil, '-h', '--help', 'help'
77
+ @out.puts USAGE
78
+ @out.puts get_started_hint if nothing_configured?
79
+ when '-v', '--version', 'version' then @out.puts "easy_sync #{VERSION}"
80
+ when 'add-source' then add_source(@argv)
81
+ when 'remove-source' then remove_source(@argv)
82
+ when 'sources' then list_sources
83
+ when 'sync' then sync(@argv)
84
+ when 'eject' then return eject(@argv)
85
+ when 'register-drive' then register_drive(@argv)
86
+ when 'replace-drive' then replace_drive(@argv)
87
+ when 'restore' then restore(@argv)
88
+ when 'status' then status(@argv)
89
+ when 'history' then history(@argv.first)
90
+ when 'reassign' then reassign(@argv)
91
+ when 'rename-drive' then rename_drive(@argv)
92
+ when 'forget-drive' then forget_drive(@argv)
93
+ when 'verify-drive' then verify_drive(@argv)
94
+ when 'pending' then pending
95
+ when 'clean' then clean(@argv)
96
+ when 'scrub' then return scrub(@argv)
97
+ when 'benchmark' then return benchmark(@argv)
98
+ when 'plan' then plan(@argv)
99
+ when 'dashboard' then dashboard
100
+ else
101
+ @err.puts "Unknown command: #{command}\n\n#{USAGE}"
102
+ return 1
103
+ end
104
+ 0
105
+ rescue Error, OptionParser::ParseError => e
106
+ @err.puts "error: #{e.message}"
107
+ 1
108
+ rescue Errno::ENOENT => e
109
+ # du/df/diskutil/rsync/smartctl are all invoked without checking they
110
+ # exist first (only rsync gets a dedicated check, in `sync`); this turns
111
+ # a missing one into the same calm message every other failure gets,
112
+ # instead of a raw Ruby backtrace.
113
+ @err.puts "error: #{e.message} (a required command-line tool is missing)"
114
+ 1
115
+ rescue Interrupt
116
+ # Ctrl-C. Everything is resumable: the lock and log are released by
117
+ # their ensure blocks, caffeinate exits with us, rsync's own temp file for
118
+ # an in-flight rsync copy is removed by rsync, and whatever folder or
119
+ # file was in progress simply gets processed again next run.
120
+ @err.puts "\nInterrupted. Nothing is lost: run the same command again to pick up where this left off."
121
+ 130
122
+ end
123
+
124
+ private
125
+
126
+ # Help must never fail, whatever state the config is in.
127
+ def nothing_configured?
128
+ config.source_entries.empty?
129
+ rescue StandardError
130
+ false
131
+ end
132
+
133
+ def get_started_hint
134
+ "Nothing is configured yet. To get started:\n" \
135
+ " easy_sync add-source /Volumes/<share> once per NAS share\n" \
136
+ " easy_sync register-drive /Volumes/<drive> once per backup drive\n" \
137
+ " easy_sync plan --apply\n" \
138
+ " easy_sync sync --dry-run\n" \
139
+ " easy_sync sync\n"
140
+ end
141
+
142
+ # Global options come before the command: `easy_sync --config x sync`.
143
+ def parse_global_options!
144
+ while (arg = @argv.first)
145
+ case arg
146
+ when '--config'
147
+ @argv.shift
148
+ @config_path = @argv.shift or raise Error, "--config needs a path\n\n#{USAGE}"
149
+ when /\A--config=(.+)\z/
150
+ @argv.shift
151
+ @config_path = Regexp.last_match(1)
152
+ else
153
+ break
154
+ end
155
+ end
156
+ end
157
+
158
+ def config
159
+ @config ||= begin
160
+ cfg, status = Config.load(@config_path)
161
+ @err.puts "Generated sample config file: #{@config_path}" if status == :generated
162
+ cfg
163
+ end
164
+ end
165
+
166
+ def settings = config.settings
167
+
168
+ def manifest
169
+ @manifest ||= Jbod::Manifest.open(settings[:manifest_path])
170
+ end
171
+
172
+ def volume_info
173
+ @volume_info ||= Jbod::VolumeInfo.new(mount_root: settings[:mount_root], shell: @shell)
174
+ end
175
+
176
+ def sync(args)
177
+ opts = { dry_run: false, purge: nil, keep_awake: settings.fetch(:keep_awake, true), accept: false }
178
+ OptionParser.new do |o|
179
+ o.on('--dry-run', 'Show what rsync and the purge would do without changing anything') { opts[:dry_run] = true }
180
+ o.on('--no-purge', 'Sync but do not delete expired files from the drives') { opts[:purge] = false }
181
+ o.on('--no-keep-awake', 'Let the Mac sleep during this run (default: caffeinate keeps it awake)') { opts[:keep_awake] = false }
182
+ o.on('--accept-changes', 'Let this run copy a bulk change the tripwire stopped (every folder, or the ones named)') { opts[:accept] = true }
183
+ end.parse!(args)
184
+ raise Error, "unexpected argument#{'s' if args.size != 1} #{args.join(' ')} (folders go after --accept-changes)" if !opts[:accept] && args.any?
185
+
186
+ # Never saved: a later, unrelated bulk change still stops.
187
+ accept = opts[:accept] && (args.empty? || args.map { |a| a.chomp('/') })
188
+ version = Jbod::Mirror.check_version!(@shell)
189
+ report = nil
190
+ Jbod::RunLock.new(settings[:lock_path]).acquire(kind: 'sync') do
191
+ log = Jbod::RunLog.open(settings[:log_dir], keep: settings[:keep_logs], out: @out, clock: @clock)
192
+ begin
193
+ log.puts "easy_sync #{VERSION} · #{@clock.now.strftime('%Y-%m-%d %H:%M:%S %Z')} · rsync #{version}" \
194
+ "#{' · DRY RUN' if opts[:dry_run]} · log #{log.path}"
195
+ log.puts 'Keeping the Mac awake for this run (caffeinate).' if opts[:keep_awake] && @keep_awake.start
196
+ report = Jbod::Runner.new(settings, manifest: manifest, volume_info: volume_info, shell: @shell.with_out(log),
197
+ out: log, dry_run: opts[:dry_run], purge: opts[:purge], clock: @clock,
198
+ accept_changes: accept || nil).run
199
+ log.puts 'Sync finished (ran to completion, not interrupted).'
200
+ rescue Interrupt
201
+ # Without this, the only way to tell an interrupted run from a
202
+ # completed one is the absence of the line above - which meant
203
+ # reading two log files and comparing timestamps to work out that a
204
+ # "still running" status was actually a fresh restart after Ctrl-C.
205
+ log.puts 'Sync interrupted (Ctrl-C) before it finished. Nothing was lost; a later `sync` resumes it.'
206
+ raise
207
+ ensure
208
+ log.close
209
+ end
210
+ end
211
+ return if opts[:dry_run] || report.tripped.empty?
212
+
213
+ raise Error, report.run_tripped ? 'the tripwire stopped this sync; nothing was copied (see the log above)' \
214
+ : "the tripwire held back #{report.tripped.join(', ')} (see the log above)"
215
+ end
216
+
217
+ # Removes anything matching exclude_folders from the placed folders on
218
+ # every mounted drive, without waiting for the deletion grace period.
219
+ def clean(args)
220
+ dry_run = false
221
+ OptionParser.new { |o| o.on('--dry-run', 'List what would be removed') { dry_run = true } }.parse!(args)
222
+ # A dry run only reads, so it may look while a sync is running.
223
+ lock = dry_run ? ->(**, &blk) { blk.call } : Jbod::RunLock.new(settings[:lock_path]).method(:acquire)
224
+ lock.call(kind: 'clean') do
225
+ mounted = volume_info.mounted_drives(manifest.drives)
226
+ raise Error, 'no registered drive is mounted' if mounted.empty?
227
+
228
+ @out.puts "#{dry_run ? 'Would remove' : 'Removing'} entries matching #{settings[:exclude_folders].join(', ')} " \
229
+ "from #{mounted.map(&:friendly_name).join(', ')}:"
230
+ result = Jbod::Cleaner.new(manifest, excludes: settings[:exclude_folders], out: @out).run(mounted, dry_run: dry_run)
231
+ if dry_run
232
+ @out.puts "#{result.would_remove.size} entr#{result.would_remove.size == 1 ? 'y' : 'ies'} would be removed."
233
+ else
234
+ @out.puts "Removed #{result.removed.size} entr#{result.removed.size == 1 ? 'y' : 'ies'}, " \
235
+ "#{Jbod::Placement.format_bytes(result.bytes)} freed."
236
+ end
237
+ end
238
+ end
239
+
240
+ # Reads every tracked file on one or more drives back off the platter and
241
+ # compares it to its SHA-256 baseline, catching bit rot that rsync's
242
+ # quick size/mtime check cannot see. See docs/integrity-scan.md.
243
+ def scrub(args)
244
+ opts = { dry_run: false, all: false, keep_awake: settings.fetch(:keep_awake, true), jobs: settings[:scrub_jobs] }
245
+ OptionParser.new do |o|
246
+ o.on('--all', 'Scrub every mounted, non-retired drive, stalest first') { opts[:all] = true }
247
+ o.on('--jobs N', Integer, 'Scrub this many drives at once (default: config scrub_jobs)') { |v| opts[:jobs] = v }
248
+ o.on('--for DURATION', 'Stop after this long (e.g. 90m, 8h, 2d); default runs to completion') { |v| opts[:for] = v }
249
+ o.on('--dry-run', 'Walk and report what would be hashed, without hashing or writing anything') { opts[:dry_run] = true }
250
+ o.on('--no-keep-awake', 'Let the Mac sleep during this run (default: caffeinate keeps it awake)') { opts[:keep_awake] = false }
251
+ end.parse!(args)
252
+ raise Error, "scrub takes drive NAMEs or --all, not both\n\n#{USAGE}" if opts[:all] && args.any?
253
+ raise Error, '--jobs must be a positive integer' unless opts[:jobs].is_a?(Integer) && opts[:jobs].positive?
254
+
255
+ deadline = opts[:for] ? @clock.now + parse_duration(opts[:for]) : nil
256
+ targets = resolve_scrub_targets(args, all: opts[:all])
257
+ raise Error, 'no mounted, non-retired drive to scrub' if targets.empty?
258
+
259
+ results = []
260
+ lock = Jbod::RunLock.new(settings[:lock_path])
261
+ lock.acquire(kind: 'scrub') do
262
+ log = Jbod::RunLog.open(settings[:log_dir], keep: settings[:keep_logs], out: @out, clock: @clock, prefix: 'scrub')
263
+ begin
264
+ log.puts "easy_sync #{VERSION} scrub · #{@clock.now.strftime('%Y-%m-%d %H:%M:%S %Z')}" \
265
+ "#{' · DRY RUN' if opts[:dry_run]}#{" · jobs #{opts[:jobs]}" if opts[:jobs] > 1} · log #{log.path}"
266
+ log.puts 'Keeping the Mac awake for this run (caffeinate).' if opts[:keep_awake] && @keep_awake.start
267
+ pool = Jbod::ScrubPool.new(jobs: opts[:jobs], open_manifest: -> { Jbod::Manifest.open(settings[:manifest_path]) },
268
+ scrubber_options: { excludes: settings[:exclude_folders], dry_run: opts[:dry_run] },
269
+ lock: lock, clock: @clock, deadline: deadline, out: log)
270
+ results = pool.run(targets)
271
+ stopped = results.select(&:stopped_reason).map do |r|
272
+ "#{r.drive} #{r.stopped_reason == :deadline ? 'hit the --for deadline' : 'was unmounted'}"
273
+ end
274
+ if stopped.empty?
275
+ log.puts 'Scrub finished (ran to completion, not interrupted).'
276
+ else
277
+ log.puts "Scrub stopped early (#{stopped.join('; ')}). Nothing was lost; a later `scrub` resumes it."
278
+ end
279
+ rescue Interrupt
280
+ log.puts 'Scrub interrupted (Ctrl-C) before it finished. Nothing was lost; a later `scrub` resumes it.'
281
+ raise
282
+ ensure
283
+ log.close
284
+ end
285
+ end
286
+ results.any?(&:findings?) ? 1 : 0
287
+ end
288
+
289
+ # Named drives, in the order given (an unknown or unmounted name is an
290
+ # error, a retired one is refused); otherwise every mounted, non-retired
291
+ # drive ordered stalest (oldest #scrubbed_through, NULLs/never-scrubbed
292
+ # first) to freshest, trimmed to just the stalest one unless +all+.
293
+ def resolve_scrub_targets(names, all:)
294
+ mounted_by_serial = volume_info.mounted_drives(manifest.drives).to_h { |m| [m.serial_number, m] }
295
+ if names.any?
296
+ names.map do |name|
297
+ drive = manifest.drive_by_name(name) or raise Error, "no drive named #{name}"
298
+ raise Error, "#{name} is retired" if drive.retired?
299
+
300
+ mounted_by_serial[drive.serial_number] or raise Error, "#{name} is not mounted"
301
+ end
302
+ else
303
+ stalest = manifest.drives.select { |d| mounted_by_serial.key?(d.serial_number) }
304
+ .sort_by { |d| [manifest.scrubbed_through(d.serial_number) || '', d.friendly_name] }
305
+ .map { |d| mounted_by_serial[d.serial_number] }
306
+ all ? stalest : stalest.first(1)
307
+ end
308
+ end
309
+
310
+ DEFAULT_BENCHMARK_SIZE = '8gb' # what docs/performance.md's baseline numbers were measured with
311
+
312
+ # Writes a test file to each drive, reads it back, deletes it, and keeps
313
+ # the result (the last Manifest::BENCHMARKS_KEPT per drive), so a drive
314
+ # that is slowing down stands out against its own history. One drive at a
315
+ # time: drives measured together would share the enclosure and skew each
316
+ # other. Exits 1 if a drive failed or came out well below its own median.
317
+ def benchmark(args)
318
+ opts = { all: false, history: false, size: DEFAULT_BENCHMARK_SIZE }
319
+ OptionParser.new do |o|
320
+ o.on('--all', 'Benchmark every mounted, non-retired drive, one at a time') { opts[:all] = true }
321
+ o.on('--size SIZE', "Test file size (default #{DEFAULT_BENCHMARK_SIZE}; the drive needs this much free plus the reserve)") do |v|
322
+ opts[:size] = v
323
+ end
324
+ o.on('--history', 'List the kept runs instead of running a new one') { opts[:history] = true }
325
+ end.parse!(args)
326
+ raise Error, "benchmark takes drive NAMEs or --all, not both\n\n#{USAGE}" if opts[:all] && args.any?
327
+ return benchmark_history(args) if opts[:history]
328
+
329
+ size = Jbod::Placement.parse_size(opts[:size])
330
+ raise Error, "invalid --size #{opts[:size].inspect}" unless size&.positive?
331
+
332
+ targets = resolve_benchmark_targets(args, all: opts[:all])
333
+ raise Error, 'no mounted, non-retired drive to benchmark' if targets.empty?
334
+
335
+ ok = true
336
+ lock = Jbod::RunLock.new(settings[:lock_path])
337
+ lock.acquire(kind: 'benchmark') do
338
+ @out.puts 'Keeping the Mac awake for this run (caffeinate).' if settings.fetch(:keep_awake, true) && @keep_awake.start
339
+ benchmarker = Jbod::Benchmarker.new
340
+ targets.each do |m|
341
+ lock.note(m.friendly_name)
342
+ ok = benchmark_drive(benchmarker, m, size) && ok
343
+ end
344
+ end
345
+ ok ? 0 : 1
346
+ end
347
+
348
+ # Named drives, in the order given; otherwise every mounted, non-retired
349
+ # drive, the one benchmarked longest ago (never, first) leading, trimmed
350
+ # to just that one unless +all+. Mirrors #resolve_scrub_targets.
351
+ def resolve_benchmark_targets(names, all:)
352
+ mounted_by_serial = volume_info.mounted_drives(manifest.drives).to_h { |m| [m.serial_number, m] }
353
+ if names.any?
354
+ names.map do |name|
355
+ drive = manifest.drive_by_name(name) or raise Error, "no drive named #{name}"
356
+ raise Error, "#{name} is retired" if drive.retired?
357
+
358
+ mounted_by_serial[drive.serial_number] or raise Error, "#{name} is not mounted"
359
+ end
360
+ else
361
+ oldest = manifest.drives.select { |d| mounted_by_serial.key?(d.serial_number) }
362
+ .sort_by { |d| [manifest.last_benchmarked_at(d.serial_number) || '', d.friendly_name] }
363
+ .map { |d| mounted_by_serial[d.serial_number] }
364
+ all ? oldest : oldest.first(1)
365
+ end
366
+ end
367
+
368
+ # Returns false when the drive failed or came out slower than usual; a
369
+ # drive without room for the test file is skipped, which is not a failure.
370
+ def benchmark_drive(benchmarker, mounted, size)
371
+ name = mounted.friendly_name
372
+ reserve = settings.fetch(:reserve_bytes, 0)
373
+ if mounted.free_bytes.to_i < size + reserve
374
+ @out.puts "#{name}: skipped, only #{bytes(mounted.free_bytes)} free; the test file needs #{bytes(size)} " \
375
+ "plus the #{bytes(reserve)} reserve (a smaller --size fits)"
376
+ return true
377
+ end
378
+
379
+ @out.puts "#{name}: writing #{bytes(size)}, then reading it back..."
380
+ result = benchmarker.run(mounted, size: size)
381
+ unless result.ok?
382
+ @out.puts " FAILED: #{result.error}"
383
+ return false
384
+ end
385
+
386
+ earlier = manifest.benchmarks(mounted.serial_number)
387
+ manifest.record_benchmark(mounted.serial_number, bytes: size, write_mb_s: result.write_mb_s.round(1),
388
+ read_mb_s: result.read_mb_s.round(1), used_bytes: result.used_bytes,
389
+ at: @clock.now.utc.iso8601)
390
+ report_benchmark(result, Jbod::Benchmarker.compare(earlier))
391
+ end
392
+
393
+ def report_benchmark(result, cmp)
394
+ @out.puts " write #{mb_s(result.write_mb_s)}, read #{mb_s(result.read_mb_s)} (drive #{bytes(result.used_bytes)} used)"
395
+ if [result.write_mb_s, result.read_mb_s].max > 1000
396
+ @out.puts ' Over 1 GB/s: an SSD, or the page cache was measured instead of the drive (see docs/performance.md).'
397
+ end
398
+ if cmp.earlier.zero?
399
+ @out.puts ' First run for this drive; later runs are compared against it.'
400
+ return true
401
+ end
402
+
403
+ @out.puts " vs. median of #{cmp.earlier} earlier run#{'s' if cmp.earlier != 1}: " \
404
+ "write #{mb_s(cmp.write_median)} (#{signed_pct(cmp.write_change(result.write_mb_s))}), " \
405
+ "read #{mb_s(cmp.read_median)} (#{signed_pct(cmp.read_change(result.read_mb_s))})" \
406
+ "#{" - a slowdown is only flagged from #{Jbod::Benchmarker::MIN_HISTORY} earlier runs on" unless cmp.enough?}"
407
+ slower = cmp.slower(result.write_mb_s, result.read_mb_s)
408
+ return true if slower.empty?
409
+
410
+ @out.puts " SLOWER than usual (#{slower.join(' and ')}). Runs normally vary by about 7%; re-run to confirm. " \
411
+ 'A drive that has filled up since writes to slower inner tracks; otherwise this can be an early ' \
412
+ 'failure sign SMART does not show yet - check `status`.'
413
+ false
414
+ end
415
+
416
+ def benchmark_history(names)
417
+ drives = names.any? ? names.map { |n| manifest.drive_by_name(n) or raise Error, "no drive named #{n}" } : manifest.drives
418
+ drives.each_with_index do |d, i|
419
+ runs = manifest.benchmarks(d.serial_number)
420
+ @out.puts if i.positive?
421
+ @out.puts "#{d.friendly_name}#{runs.empty? ? ': never benchmarked' : " (#{runs.size} run#{'s' if runs.size != 1}, newest first):"}"
422
+ next if runs.empty?
423
+
424
+ print_table(%w[WHEN WRITE READ SIZE USED],
425
+ runs.map { |r| [local_time(r.run_at, '%Y-%m-%d %H:%M'), mb_s(r.write_mb_s), mb_s(r.read_mb_s), bytes(r.bytes), bytes(r.used_bytes)] },
426
+ right: [1, 2, 3, 4])
427
+ end
428
+ 0
429
+ end
430
+
431
+ def mb_s(value) = "#{format('%.1f', value)} MB/s"
432
+ def signed_pct(fraction) = fraction ? format('%+d%%', (fraction * 100).round) : '?'
433
+
434
+ def parse_duration(spec)
435
+ m = spec.match(/\A(\d+)([mhd])\z/) or raise Error, "invalid --for duration #{spec.inspect} (use e.g. 90m, 8h, 2d)"
436
+
437
+ m[1].to_i * { 'm' => 60, 'h' => 3600, 'd' => 86_400 }.fetch(m[2])
438
+ end
439
+
440
+ def pending
441
+ rows = manifest.pending_deletions
442
+ if rows.empty?
443
+ @out.puts 'Nothing is pending deletion.'
444
+ return
445
+ end
446
+ now = Time.now
447
+ @out.puts "#{rows.size} pending (deleted after #{settings[:grace_days]} days and #{settings[:grace_runs]} runs missing, " \
448
+ 'or once a reassigned folder is verified synced to its new drive):'
449
+ rows.each do |p|
450
+ label = p.whole_folder? ? "#{p.folder_path} (whole folder)" : "#{p.folder_path}/#{p.relative_path}"
451
+ @out.puts " #{label.ljust(50)} since #{p.first_missing_at} #{pending_state(p, now)}"
452
+ end
453
+ end
454
+
455
+ def pending_state(p, now)
456
+ grace_days, grace_runs = settings[:grace_days], settings[:grace_runs]
457
+ expired = p.expired?(now: now, grace_days: grace_days, grace_runs: grace_runs)
458
+
459
+ unless p.reassigned?
460
+ return expired ? 'EXPIRED, deleted on next sync' : "expires #{p.expires_at(grace_days).strftime('%Y-%m-%d')}, seen missing #{p.missing_runs}x"
461
+ end
462
+
463
+ old_drive = manifest.drive(p.drive_serial)&.friendly_name || p.drive_serial
464
+ synced = manifest.folder(p.folder_path)&.last_sync_status == 'ok'
465
+ if expired && synced
466
+ 'EXPIRED, deleted on next sync'
467
+ elsif synced
468
+ "verified on its new drive; old copy on #{old_drive} goes on #{p.expires_at(grace_days).strftime('%Y-%m-%d')}"
469
+ else
470
+ "old copy on #{old_drive}; waiting for a verified sync to its new drive before this can be deleted"
471
+ end
472
+ end
473
+
474
+ # Adds a share. Each of its top-level folders is placed on its own
475
+ # (kept with the rest of the share while a drive has room); loose
476
+ # top-level files are one more unit.
477
+ def add_source(args)
478
+ path = args.first or raise Error, "add-source needs the share's path, e.g. /Volumes/tv\n\n#{USAGE}"
479
+ path = File.expand_path(path)
480
+ raise Error, "#{path} is not mounted (or is empty)" unless Dir.exist?(path) && !Dir.empty?(path)
481
+
482
+ config.add_source(path)
483
+ config.save
484
+ @out.puts "Added #{path}. Config: #{config.path}"
485
+ end
486
+
487
+ def remove_source(args)
488
+ path = args.first or raise Error, "remove-source needs the share's path\n\n#{USAGE}"
489
+ config.remove_source(File.expand_path(path))
490
+ config.save
491
+ @out.puts "Removed #{File.expand_path(path)}. Nothing on the drives was touched; folders already placed stay " \
492
+ 'in the manifest (they will be reported as missing on the NAS and follow the deletion grace period).'
493
+ end
494
+
495
+ def list_sources
496
+ entries = config.source_entries
497
+ if entries.empty?
498
+ @out.puts "No sources configured. Add one with: easy_sync add-source /Volumes/<share>"
499
+ return
500
+ end
501
+ entries.each do |e|
502
+ state = Dir.exist?(e[:path]) && !Dir.empty?(e[:path]) ? 'mounted' : 'NOT MOUNTED'
503
+ @out.puts " #{e[:path].ljust(32)} #{state}"
504
+ end
505
+ end
506
+
507
+ # Measures every configured share (read-only) and says whether each of
508
+ # its folders fits on the largest drive.
509
+ def plan(args)
510
+ opts = {}
511
+ OptionParser.new do |o|
512
+ o.on('--largest-drive SIZE', 'Capacity of the biggest drive you will register, e.g. 8tb (default: from the manifest)') do |v|
513
+ opts[:largest] = Jbod::Placement.parse_size(v)
514
+ end
515
+ end.parse!(args)
516
+ only = args.empty? ? nil : args
517
+ largest = opts[:largest] || manifest.drives.map(&:capacity_bytes).max
518
+ @out.puts(largest ? "Judging against the largest drive: #{Jbod::Placement.format_bytes(largest)}" \
519
+ : 'No drives registered yet; pass --largest-drive 8tb to check what fits')
520
+ rows = Jbod::Planner.new(settings, shell: @shell, largest_drive_bytes: largest).rows(only: only)
521
+ raise Error, "no configured source matches #{only.join(', ')}" if only && rows.empty?
522
+
523
+ rows.each do |r|
524
+ @out.puts "\n#{r.source.path}"
525
+ unless r.mounted
526
+ @out.puts " #{r.reason}"
527
+ next
528
+ end
529
+ @out.puts " #{Jbod::Placement.format_bytes(r.size_bytes)} in #{r.subfolders} folder#{'s' if r.subfolders != 1}" \
530
+ "#{r.largest_name ? ", largest #{r.largest_name} (#{Jbod::Placement.format_bytes(r.largest_subfolder)})" : ''}" \
531
+ "#{r.loose_files.positive? ? ", #{r.loose_files} loose file#{'s' if r.loose_files != 1}" : ''}"
532
+ @out.puts " #{r.reason}"
533
+ end
534
+ end
535
+
536
+ def register_drive(args)
537
+ opts = {}
538
+ OptionParser.new do |o|
539
+ o.on('--name NAME', 'Friendly name, e.g. backup-04-8tb (defaults to the volume name)') { |v| opts[:name] = v }
540
+ o.on('--serial SERIAL', 'Serial to use, skipping auto-detection (default: smartctl, falling back to the APFS Volume UUID)') { |v| opts[:serial] = v }
541
+ end.parse!(args)
542
+ mount_point = args.first or raise Error, "register-drive needs a mount point\n\n#{USAGE}"
543
+ raise Error, "#{mount_point} is not mounted" unless Dir.exist?(mount_point)
544
+
545
+ existing = volume_info.read_marker(mount_point)
546
+ raise Error, "#{mount_point} already carries a marker for #{existing[:serial_number]} (#{existing[:friendly_name]})" if existing
547
+
548
+ name = opts[:name] || File.basename(mount_point)
549
+ uuid = volume_info.volume_uuid(mount_point)
550
+ serial, source = resolve_serial(opts[:serial], mount_point, uuid)
551
+ serial or raise Error, 'could not determine a serial via smartctl or diskutil; pass --serial'
552
+ @out.puts "Using #{source} as the serial number." unless opts[:serial]
553
+ usage = volume_info.usage(mount_point)
554
+ model = volume_info.smartctl_model(mount_point)
555
+
556
+ drive = manifest.register_drive(serial_number: serial, friendly_name: name, model: model,
557
+ capacity_bytes: usage.capacity_bytes, volume_uuid: uuid)
558
+ manifest.update_drive_usage(serial, used_bytes: usage.used_bytes, free_bytes: usage.free_bytes)
559
+ health = volume_info.smart_health(mount_point)
560
+ manifest.update_drive_health(serial, status: health.status, detail: health.detail)
561
+ volume_info.write_marker(mount_point, serial_number: serial, friendly_name: name)
562
+ @out.puts "Registered #{drive.friendly_name} (#{drive.serial_number}#{drive.branded_model ? ", #{drive.branded_model}" : ''}), " \
563
+ "#{Jbod::Placement.format_bytes(drive.capacity_bytes)} at #{mount_point}"
564
+ @out.puts "SMART: #{health.status} (#{health.detail})"
565
+ end
566
+
567
+ # Retires OLD. With --to NEW every folder on OLD is recorded as living on
568
+ # NEW; without it the folders are forgotten so the next sync places them
569
+ # afresh across whatever is mounted. --copy first rsyncs OLD's contents to
570
+ # NEW over the local bus (both must be mounted), so a readable old drive
571
+ # never has to be re-pulled from the NAS.
572
+ def replace_drive(args)
573
+ opts = { copy: false }
574
+ OptionParser.new do |o|
575
+ o.on('--to NEW_NAME', 'Registered drive that takes over every folder of the old one') { |v| opts[:to] = v }
576
+ o.on('--copy', 'Copy the old drive onto the new one locally first (both mounted)') { opts[:copy] = true }
577
+ o.on('--note TEXT') { |v| opts[:note] = v }
578
+ end.parse!(args)
579
+ old_name = args.first or raise Error, "replace-drive needs the old drive's name\n\n#{USAGE}"
580
+ old = manifest.drive_by_name(old_name) or raise Error, "no drive named #{old_name}"
581
+ raise Error, "#{old_name} is already retired" if old.retired?
582
+
583
+ new_drive = nil
584
+ if opts[:to]
585
+ new_drive = manifest.drive_by_name(opts[:to]) or raise Error, "no drive named #{opts[:to]}; register it first"
586
+ raise Error, "#{opts[:to]} is retired" if new_drive.retired?
587
+ raise Error, 'old and new drive are the same' if new_drive.serial_number == old.serial_number
588
+ end
589
+ raise Error, '--copy needs --to NEW_NAME' if opts[:copy] && !new_drive
590
+
591
+ folders = manifest.folders_on(old.serial_number)
592
+ copy_drive(old, new_drive) if opts[:copy]
593
+
594
+ note = opts[:note] || (new_drive ? "#{old.friendly_name} replaced by #{new_drive.friendly_name}" : "#{old.friendly_name} retired")
595
+ manifest.move_all_folders(old.serial_number, new_drive&.serial_number, note: note)
596
+ manifest.retire_drive(old.serial_number)
597
+
598
+ @out.puts "Retired #{old.friendly_name}."
599
+ if new_drive
600
+ @out.puts "#{folders.size} folder#{'s' if folders.size != 1} now recorded on #{new_drive.friendly_name}."
601
+ @out.puts(opts[:copy] ? 'Run `easy_sync sync` to verify them against the NAS.' \
602
+ : 'Run `easy_sync sync` to copy them there from the NAS.')
603
+ else
604
+ @out.puts "#{folders.size} folder#{'s' if folders.size != 1} forgotten; the next `easy_sync sync` will place " \
605
+ 'them afresh across the mounted drives and copy them from the NAS.'
606
+ end
607
+ end
608
+
609
+ # rsync OLD -> NEW over the local bus, excluding each drive's own .easy_sync folder.
610
+ def copy_drive(old, new_drive)
611
+ mounted = volume_info.mounted_drives([old, new_drive]).to_h { |m| [m.serial_number, m] }
612
+ src = mounted[old.serial_number] or raise Error, "#{old.friendly_name} is not mounted (needed for --copy)"
613
+ dst = mounted[new_drive.serial_number] or raise Error, "#{new_drive.friendly_name} is not mounted (needed for --copy)"
614
+ if src.used_bytes > dst.free_bytes
615
+ raise Error, "#{new_drive.friendly_name} has #{Jbod::Placement.format_bytes(dst.free_bytes)} free but " \
616
+ "#{old.friendly_name} holds #{Jbod::Placement.format_bytes(src.used_bytes)}"
617
+ end
618
+ @out.puts "Copying #{old.friendly_name} -> #{new_drive.friendly_name} (#{Jbod::Placement.format_bytes(src.used_bytes)})..."
619
+ excludes = (settings[:exclude_folders] + [Jbod::DRIVE_DIR]).map { |e| "--exclude=#{e}" }
620
+ result = @shell.run(['rsync', '-a', '--partial', '--stats', '--info=progress2', *excludes, "#{src.mount_point}/", "#{dst.mount_point}/"])
621
+ raise Error, "copy failed (rsync exit #{result.status}); nothing was changed in the manifest" unless result.success?
622
+ end
623
+
624
+ # The reverse of `sync`: copies folders from their drives back onto the
625
+ # NAS. Never deletes anything already on the NAS. A dry run only reads
626
+ # (rsync --dry-run plus no directory creation), so it may run alongside a
627
+ # sync; a real restore takes the same lock a sync does.
628
+ def restore(args)
629
+ opts = { dry_run: false, all: false }
630
+ OptionParser.new do |o|
631
+ o.on('--dry-run', 'Show what would be restored without changing the NAS') { opts[:dry_run] = true }
632
+ o.on('--all', 'Restore every folder in the manifest') { opts[:all] = true }
633
+ end.parse!(args)
634
+ raise Error, "restore needs a folder or share name (e.g. tv or \"tv/Show Name\"), or --all\n\n#{USAGE}" if args.empty? && !opts[:all]
635
+
636
+ restorer = Jbod::Restorer.new(settings, manifest: manifest, shell: @shell, out: @out)
637
+ folders = opts[:all] ? manifest.folders : restorer.resolve(args)
638
+ raise Error, 'nothing to restore' if folders.empty?
639
+
640
+ lock = opts[:dry_run] ? ->(**, &blk) { blk.call } : Jbod::RunLock.new(settings[:lock_path]).method(:acquire)
641
+ result = nil
642
+ lock.call(kind: 'restore') { result = restorer.run(folders, volume_info.mounted_drives(manifest.drives), dry_run: opts[:dry_run]) }
643
+ @out.puts "\n#{opts[:dry_run] ? 'Would restore' : 'Restored'} #{result.restored.size}, " \
644
+ "skipped #{result.skipped.size}, failed #{result.failed.size}"
645
+ end
646
+
647
+ # --serial wins outright. Otherwise try the hardware serial via smartctl
648
+ # first (a real, stable serial that survives a reformat), falling back to
649
+ # the APFS Volume UUID when smartctl can't reach the drive (no smartctl
650
+ # installed, needs elevated privileges, or - common for external USB
651
+ # enclosures - the bridge chip doesn't pass SMART through at all).
652
+ def resolve_serial(explicit, mount_point, uuid)
653
+ return [explicit, 'the --serial you gave'] if explicit
654
+
655
+ if (serial = volume_info.smartctl_serial(mount_point))
656
+ [serial, 'the smartctl hardware serial']
657
+ else
658
+ [uuid, 'the diskutil Volume UUID (smartctl serial unavailable)']
659
+ end
660
+ end
661
+
662
+ def status(args)
663
+ all = false
664
+ live_smart = false
665
+ OptionParser.new do |o|
666
+ o.on('--all', 'List every placed folder, one per line (for piping)') { all = true }
667
+ o.on('--smart', 'Read SMART from each mounted drive now (shown, not saved; the next sync saves its own reading)') { live_smart = true }
668
+ end.parse!(args)
669
+ run = Jbod::RunLock.new(settings[:lock_path]).status
670
+ print_run_status(run)
671
+ print_drives(all, live_smart: live_smart)
672
+ all ? print_all_folders : print_folder_summary
673
+ print_scrub_status(run)
674
+ end
675
+
676
+ # A drive is overdue once it's gone scrub_stale_days without a full
677
+ # check (or never had one) and actually has something synced to it - an
678
+ # empty new drive is not overdue. Alongside that, the fleet-wide count of
679
+ # rows scrub has flagged as corrupt/unreadable/unresolved.
680
+ def print_scrub_status(run)
681
+ active = run&.kind == 'scrub' ? run.current : []
682
+ overdue = manifest.drives.select { |d| scrub_overdue?(d) && !active.include?(d.friendly_name) }
683
+ unless overdue.empty?
684
+ @out.puts "\nOverdue for `scrub`:"
685
+ overdue.each do |d|
686
+ through = manifest.scrubbed_through(d.serial_number)
687
+ label = through ? "scrubbed #{days_ago(through)} days ago" : 'never scrubbed'
688
+ @out.puts " #{d.friendly_name.ljust(16)} #{label}"
689
+ end
690
+ end
691
+ print_scrubbing_progress(run) unless active.empty?
692
+ findings = manifest.scrub_findings.size
693
+ return unless findings.positive?
694
+
695
+ @out.puts "\n#{findings} file#{'s' if findings != 1} flagged by scrub (corrupt, unreadable, or unresolved); " \
696
+ 'run `easy_sync scrub` to work through them.'
697
+ end
698
+
699
+ # How far each currently-scrubbing drive has gotten through this run:
700
+ # files verified since the run started, not files ever hashed (a drive
701
+ # scrubbed before already has an old digest for nearly everything, which
702
+ # would misleadingly read as "done" the instant this run started). This
703
+ # is the *only* place a currently-scrubbing drive is listed - #overdue
704
+ # above excludes it, so a drive never appears in both sections at once.
705
+ def print_scrubbing_progress(run)
706
+ @out.puts "\nScrubbing now:"
707
+ run.current.each do |name|
708
+ drive = manifest.drive_by_name(name)
709
+ progress = drive && manifest.checksum_progress(drive.serial_number, since: run.started_at.utc.iso8601)
710
+ label = if progress.nil? || progress[:total].zero?
711
+ 'scrubbing now'
712
+ else
713
+ pct = ((100.0 * progress[:checked]) / progress[:total]).round
714
+ "#{progress[:checked]}/#{progress[:total]} files checked (#{pct}%)"
715
+ end
716
+ @out.puts " #{name.ljust(16)} #{label}"
717
+ end
718
+ end
719
+
720
+ def scrub_overdue?(drive)
721
+ return false unless manifest.folders_on(drive.serial_number).any?(&:last_synced_at)
722
+
723
+ through = manifest.scrubbed_through(drive.serial_number)
724
+ through.nil? || Time.parse(through) < (@clock.now - (settings[:scrub_stale_days] * 86_400))
725
+ end
726
+
727
+ def days_ago(iso)
728
+ ((@clock.now - Time.parse(iso)) / 86_400).floor
729
+ end
730
+
731
+ RETIRED_SHOWN = 5 # most recent; older ones are still in the manifest, just not printed by default
732
+
733
+ # SMART comes from the manifest (the last sync's reading) unless
734
+ # live_smart, which reads each mounted drive now. A live reading is only
735
+ # printed: status stays read-only, and it can run while a sync holds the lock.
736
+ def print_drives(all = false, live_smart: false)
737
+ drives = manifest.drives
738
+ mounted = volume_info.mounted_drives(drives).to_h { |m| [m.serial_number, m] }
739
+ @out.puts 'Drives:'
740
+ unless drives.empty?
741
+ backblaze = Jbod::Backblaze.read
742
+ uploads = backblaze&.drive_states(drives, mounted: mounted, mount_root: settings[:mount_root],
743
+ last_copied_at: manifest.last_copied_at)
744
+ rows = drives.map do |d|
745
+ m = mounted[d.serial_number]
746
+ [d.friendly_name, d.branded_model ? "#{d.serial_number} · #{d.branded_model}" : d.serial_number,
747
+ m ? Jbod::Placement.format_bytes(m.free_bytes) : '—',
748
+ m ? Jbod::Placement.format_bytes(m.used_bytes) : '—',
749
+ m && live_smart ? live_smart_summary(m) : smart_summary(d),
750
+ *(uploads ? [uploads[d.serial_number].label] : []), drive_note(d, m)]
751
+ end
752
+ print_table(%w[DRIVE SERIAL FREE USED SMART] + (uploads ? ['BACKBLAZE'] : []) + [''], rows, right: [2, 3])
753
+ if live_smart
754
+ @out.puts
755
+ @out.puts ' SMART read just now from the mounted drives (not saved); unmounted drives show their last reading.'
756
+ end
757
+ total_capacity = drives.sum(&:capacity_bytes)
758
+ total_free = drives.sum { |d| (mounted[d.serial_number]&.free_bytes || d.last_free_bytes).to_i }
759
+ @out.puts
760
+ @out.puts "Total: #{bytes(total_capacity)} capacity, #{bytes(total_free)} free right now"
761
+ print_backblaze_summary(backblaze, uploads) if uploads
762
+ end
763
+ retired = manifest.drives(include_retired: true).select(&:retired?).sort_by(&:retired_at).reverse
764
+ return if retired.empty?
765
+
766
+ shown = all ? retired : retired.first(RETIRED_SHOWN)
767
+ hidden = retired.size - shown.size
768
+ line = " Retired: #{shown.map { |d| "#{d.friendly_name} (#{local_time(d.retired_at, '%Y-%m-%d')})" }.join(', ')}"
769
+ line += " · #{hidden} more (see `status --all`)" if hidden.positive?
770
+ @out.puts if drives.any?
771
+ @out.puts line
772
+ end
773
+
774
+ # One line answering "can I power the drives off?": Backblaze uploads
775
+ # from the drives, so anything not up to date only reaches it the next
776
+ # time that drive is connected.
777
+ def print_backblaze_summary(backblaze, uploads)
778
+ pending = uploads.values.count { |s| !s.done? }
779
+ verdict = pending.zero? ? "every drive's data is uploaded" : "#{pending} of #{uploads.size} drives not up to date yet"
780
+ completed = backblaze.last_completed_at ? "; last backup pass finished #{local_time(backblaze.last_completed_at.iso8601, '%Y-%m-%d %H:%M')}" : ''
781
+ @out.puts "Backblaze: #{verdict}#{completed}"
782
+ end
783
+
784
+ def print_table(header, rows, right: [])
785
+ all = [header] + rows
786
+ widths = header.each_index.map { |i| all.map { |r| r[i].to_s.length }.max }
787
+ all.each do |r|
788
+ cells = r.each_with_index.map { |c, i| right.include?(i) ? c.to_s.rjust(widths[i]) : c.to_s.ljust(widths[i]) }
789
+ @out.puts " #{cells.join(' ')}".rstrip
790
+ end
791
+ end
792
+
793
+ SMART_STATUS_LABELS = { 'degraded_stable' => 'stable wear' }.freeze
794
+
795
+ # The verdict plus only what's worth reading: zero counters and the
796
+ # PASSED verdict (implied by ok/warning) are dropped; ok keeps just the temperature.
797
+ # An n/a says when it was read: a stale one (a single failed read) stays
798
+ # until the next sync reads the drive again.
799
+ def smart_summary(drive)
800
+ return 'unchecked' unless drive.smart_status
801
+ if drive.smart_status == 'unknown'
802
+ return drive.smart_checked_at ? "n/a (as of #{local_time(drive.smart_checked_at, '%Y-%m-%d %H:%M')})" : 'n/a'
803
+ end
804
+
805
+ format_smart(drive.smart_status, drive.smart_detail)
806
+ end
807
+
808
+ def live_smart_summary(mounted_drive)
809
+ health = volume_info.smart_health(mounted_drive.mount_point)
810
+ return 'n/a (read now)' if health.status == 'unknown'
811
+
812
+ format_smart(Jbod::Runner.alert_status(manifest, mounted_drive.serial_number, health), health.detail)
813
+ end
814
+
815
+ def format_smart(status, detail)
816
+ parts = detail.to_s.split(' · ').reject { |p| p == 'PASSED' || p.match?(/\A[a-z ]+ 0\z/) }
817
+ parts = parts.grep(/°C\z/) if status == 'ok'
818
+ [SMART_STATUS_LABELS.fetch(status, status), *parts].join(' · ')
819
+ end
820
+
821
+ # Only the unusual: not mounted, locked, or mounted somewhere other than under its own name.
822
+ def drive_note(drive, mounted)
823
+ if mounted
824
+ File.basename(mounted.mount_point) == drive.friendly_name ? '' : "at #{mounted.mount_point}"
825
+ elsif volume_info.locked?(drive.friendly_name)
826
+ "connected but LOCKED (unlock it: diskutil apfs unlockVolume #{drive.friendly_name})"
827
+ else
828
+ "not mounted (last seen #{drive.last_seen_at ? local_time(drive.last_seen_at, '%Y-%m-%d %H:%M') : 'never'})"
829
+ end
830
+ end
831
+
832
+ def local_time(iso, format)
833
+ Time.parse(iso).localtime.strftime(format)
834
+ rescue ArgumentError, TypeError
835
+ iso
836
+ end
837
+
838
+ # Counts only: how many folders are placed/not backed up/empty, and
839
+ # whether any placed folder's last sync failed. The full per-folder
840
+ # breakdown lives in the dashboard (grouped per drive) or `status --all`.
841
+ def print_folder_summary
842
+ folders = manifest.folders
843
+ inventory = manifest.source_inventory
844
+ @out.puts "\nFolders:"
845
+ if inventory.empty?
846
+ @out.puts " #{folders.size} placed (#{Jbod::Placement.format_bytes(folders.sum { |f| f.size_bytes.to_i })})"
847
+ else
848
+ unplaced = inventory.select { |e| e.state == 'unplaced' }
849
+ empty = inventory.count { |e| e.state == 'empty' }
850
+ @out.puts " #{inventory.count { |e| e.state == 'placed' }} placed, " \
851
+ "#{unplaced.size} not backed up (#{Jbod::Placement.format_bytes(unplaced.sum { |e| e.size_bytes.to_i })}), " \
852
+ "#{empty} empty"
853
+ end
854
+ failed = folders.count { |f| f.last_sync_status == 'failed' }
855
+ @out.puts " #{failed} folder#{'s' unless failed == 1} failed their last sync" if failed.positive?
856
+ @out.puts ' (use `status --all` to list every placed folder, or `dashboard` for the full report)'
857
+ end
858
+
859
+ def print_all_folders
860
+ names = manifest.drives(include_retired: true).to_h { |d| [d.serial_number, d.friendly_name] }
861
+ folders = manifest.folders
862
+ width = folders.map { |f| f.folder_path.length }.max.to_i
863
+ @out.puts "\nFolders:"
864
+ folders.each do |f|
865
+ @out.puts " #{f.folder_path.ljust(width)} #{names.fetch(f.drive_serial, f.drive_serial).ljust(16)} " \
866
+ "#{Jbod::Placement.format_bytes(f.size_bytes).rjust(10)} last synced #{f.last_synced_at || 'never'} " \
867
+ "#{f.last_sync_status}"
868
+ end
869
+ end
870
+
871
+ # A stale lock (its process no longer running) is reported as not running,
872
+ # the same way RunLock itself would reclaim it on the next `sync`. The
873
+ # lock is shared by sync/scrub/clean/restore (RunLock#kind), so this must
874
+ # say which one is actually running rather than assuming sync - the ETA
875
+ # estimate below is sync-specific and only makes sense for a real sync.
876
+ def print_run_status(run)
877
+ if run
878
+ line = "#{run.kind.capitalize} running: pid #{run.pid}, started #{run.started_at.strftime('%Y-%m-%d %H:%M:%S %Z')} " \
879
+ "(#{format_elapsed(@clock.now - run.started_at)} ago)"
880
+ line += " on #{run.current.join(', ')}" if run.kind == 'scrub' && run.current.any?
881
+ @out.puts line
882
+ if run.kind == 'sync'
883
+ eta = sync_eta(run.started_at)
884
+ @out.puts eta if eta
885
+ end
886
+ else
887
+ @out.puts 'No sync currently running.'
888
+ end
889
+ @out.puts
890
+ end
891
+
892
+ def format_elapsed(seconds) = Jbod::Placement.format_duration(seconds)
893
+
894
+ # Formats a Jbod::SyncEta::Estimate as the one-line message `status` shows
895
+ # under the running-sync line.
896
+ def sync_eta(started_at)
897
+ e = Jbod::SyncEta.for(manifest, started_at)
898
+ case e&.status
899
+ when nil then nil
900
+ when :waiting_for_first_folder
901
+ ' Estimating time remaining: still measuring/placing folders, or waiting on a large first copy to finish...'
902
+ when :waiting_for_first_transfer
903
+ " #{e.never_synced_count} folder#{'s' if e.never_synced_count != 1} never synced (#{bytes(e.never_synced_bytes)}); " \
904
+ 'still waiting for one to finish before estimating their time.'
905
+ when :estimate
906
+ " About #{format_elapsed(e.seconds)} remaining (#{e.never_synced_count} folder#{'s' if e.never_synced_count != 1} never synced, " \
907
+ "#{e.to_reverify} to re-verify) - rough estimate, NAS/network speed varies."
908
+ end
909
+ end
910
+
911
+ def bytes(value) = Jbod::Placement.format_bytes(value)
912
+
913
+ def history(folder)
914
+ names = manifest.drives(include_retired: true).to_h { |d| [d.serial_number, d.friendly_name] }
915
+ manifest.history(folder).each do |e|
916
+ @out.puts "#{e.recorded_at} #{e.event.ljust(10)} #{e.folder_path.ljust(30)} -> #{names.fetch(e.drive_serial, e.drive_serial)} #{e.note}"
917
+ end
918
+ end
919
+
920
+ # Points a folder, or every folder of a share, at another drive. Without
921
+ # --copy no data moves: the next sync copies it from the NAS. With --copy
922
+ # it is copied drive-to-drive now (much faster than the NAS), so the next
923
+ # sync only confirms it. Either way the old copy is deleted later by
924
+ # Purger, once the folder has synced OK on its new drive and grace_days
925
+ # have passed.
926
+ def reassign(args)
927
+ opts = { force: false, copy: false }
928
+ OptionParser.new do |o|
929
+ o.on('--note TEXT') { |v| opts[:note] = v }
930
+ o.on('--copy', 'Copy the data from its current drive now, instead of letting the next sync pull it from the NAS') { opts[:copy] = true }
931
+ o.on('--force', 'Reassign even though the target drive does not appear to have room') { opts[:force] = true }
932
+ end.parse!(args)
933
+ name, drive_name = args
934
+ raise Error, "reassign needs FOLDER (or SHARE) and DRIVE_NAME\n\n#{USAGE}" unless name && drive_name
935
+
936
+ drive = manifest.drive_by_name(drive_name) or raise Error, "no drive named #{drive_name}"
937
+ records = reassign_targets(name)
938
+ moving = records.reject { |r| r.drive_serial == drive.serial_number }
939
+ if moving.empty?
940
+ @out.puts "#{name} is already on #{drive.friendly_name}."
941
+ return
942
+ end
943
+ check_fits!(moving, drive) unless opts[:force]
944
+
945
+ unless opts[:copy]
946
+ moving.each { |r| manifest.reassign_folder(r.folder_path, drive.serial_number, note: opts[:note]) }
947
+ @out.puts "#{describe_targets(name, moving)} now recorded on #{drive.friendly_name}. No data was moved; " \
948
+ 'the next sync copies it from the NAS (or use --copy to copy it from its current drive now).'
949
+ return
950
+ end
951
+ Jbod::RunLock.new(settings[:lock_path]).acquire(kind: 'reassign') { copy_and_reassign(moving, drive, opts[:note]) }
952
+ end
953
+
954
+ # A folder_path names that one folder. A share name (which is also the
955
+ # key of the share's root-files unit) names every folder of the share.
956
+ def reassign_targets(name)
957
+ exact = manifest.folder(name)
958
+ return [exact] if exact && !exact.root?
959
+
960
+ records = manifest.folders.select { |f| f.share == name && (f.folder_path == name || f.folder_path.start_with?("#{name}/")) }
961
+ raise Error, "#{name} is not in the manifest" if records.empty?
962
+
963
+ records
964
+ end
965
+
966
+ def describe_targets(name, records)
967
+ records.size == 1 ? "#{records.first.folder_path} is" : "#{records.size} folders of #{name} are"
968
+ end
969
+
970
+ def copy_and_reassign(records, drive, note)
971
+ mounted = volume_info.mounted_drives(manifest.drives).to_h { |m| [m.serial_number, m] }
972
+ dst = mounted[drive.serial_number] or raise Error, "#{drive.friendly_name} is not mounted; --copy needs both drives"
973
+ missing = records.map(&:drive_serial).uniq.reject { |s| mounted[s] }
974
+ unless missing.empty?
975
+ names = missing.map { |s| manifest.drive(s)&.friendly_name || s }
976
+ raise Error, "#{names.join(', ')} not mounted; --copy needs both drives (nothing was changed)"
977
+ end
978
+
979
+ excludes = settings[:exclude_folders].map { |e| "--exclude=#{e}" }
980
+ records.each do |r|
981
+ src = mounted[r.drive_serial]
982
+ from = File.join(src.mount_point, r.folder_path)
983
+ to = File.join(dst.mount_point, r.folder_path)
984
+ if Dir.exist?(from)
985
+ @out.puts "Copying #{r.folder_path}: #{src.friendly_name} -> #{drive.friendly_name}..."
986
+ FileUtils.mkdir_p(to)
987
+ argv = ['rsync', '-a', '--partial', '--stats', '--info=progress2', *excludes]
988
+ argv << Jbod::Mirror::ROOT_ONLY if r.root?
989
+ result = @shell.run(argv + ["#{from}/", "#{to}/"])
990
+ unless result.success?
991
+ raise Error, "copying #{r.folder_path} failed (rsync exit #{result.status}); it is still recorded on " \
992
+ "#{src.friendly_name}. Run the same command again to retry."
993
+ end
994
+ else
995
+ @out.puts "#{r.folder_path}: nothing on #{src.friendly_name} yet; the next sync copies it from the NAS."
996
+ end
997
+ manifest.reassign_folder(r.folder_path, drive.serial_number, note: note || "copied from #{src.friendly_name}")
998
+ end
999
+ @out.puts "#{describe_targets(records.first.share, records)} now on #{drive.friendly_name}. The next sync " \
1000
+ 'confirms the copy; the old one is deleted after that and the grace period.'
1001
+ end
1002
+
1003
+ # Same free-space accounting `sync` uses to place new folders: whichever
1004
+ # is smaller of live free space and (capacity minus everything already
1005
+ # promised to that drive), so reassign can't blindly send a folder
1006
+ # somewhere it won't fit either (see the backup-06-8tb overcommit this
1007
+ # was built to stop happening again).
1008
+ def check_fits!(records, drive)
1009
+ size = records.sum { |r| r.size_bytes.to_i }
1010
+ return if size.zero?
1011
+
1012
+ mounted = volume_info.mounted_drives([drive]).first
1013
+ unless mounted
1014
+ @out.puts "#{drive.friendly_name} is not mounted; couldn't check whether it has room."
1015
+ return
1016
+ end
1017
+ promised = manifest.folders_on(drive.serial_number).sum { |f| f.size_bytes.to_i }
1018
+ free = [mounted.free_bytes.to_i, drive.capacity_bytes.to_i - promised].min
1019
+ reserve = settings.fetch(:reserve_bytes, 0)
1020
+ return unless (free - reserve) < size
1021
+
1022
+ what = records.size == 1 ? records.first.folder_path : "#{records.size} folders of #{records.first.share}"
1023
+ raise Error, "#{what} (#{Jbod::Placement.format_bytes(size)}) does not fit on " \
1024
+ "#{drive.friendly_name} (#{Jbod::Placement.format_bytes(free)} free, " \
1025
+ "#{Jbod::Placement.format_bytes(reserve)} reserved). Use --force to reassign anyway."
1026
+ end
1027
+
1028
+ # Only relabels the manifest (and the drive's own marker); the tool never
1029
+ # renames the actual macOS volume itself, the same way it never unlocks
1030
+ # one. friendly_name is UNIQUE, so a name already taken by another
1031
+ # registered drive is treated as "swap these two", not an error.
1032
+ def rename_drive(args)
1033
+ old_name, new_name = args
1034
+ raise Error, "rename-drive needs OLD_NAME and NEW_NAME\n\n#{USAGE}" unless old_name && new_name
1035
+ raise Error, 'old and new name are the same' if old_name == new_name
1036
+
1037
+ old = manifest.drive_by_name(old_name) or raise Error, "no drive named #{old_name}"
1038
+ raise Error, "#{old_name} is retired" if old.retired?
1039
+ target = manifest.drive_by_name(new_name)
1040
+ mounted = volume_info.mounted_drives(manifest.drives).to_h { |m| [m.serial_number, m] }
1041
+
1042
+ if target
1043
+ manifest.swap_drive_names(old.serial_number, target.serial_number)
1044
+ @out.puts "Swapped names: #{old_name} <-> #{new_name}."
1045
+ relabel_marker(old.serial_number, new_name, mounted)
1046
+ relabel_marker(target.serial_number, old_name, mounted)
1047
+ rename_volume_hint(old.serial_number, new_name, mounted)
1048
+ rename_volume_hint(target.serial_number, old_name, mounted)
1049
+ else
1050
+ manifest.rename_drive(old.serial_number, new_name)
1051
+ @out.puts "Renamed #{old_name} to #{new_name}."
1052
+ relabel_marker(old.serial_number, new_name, mounted)
1053
+ rename_volume_hint(old.serial_number, new_name, mounted)
1054
+ end
1055
+ end
1056
+
1057
+ # Deletes retired drives and every manifest row that refers to them, for
1058
+ # drives whose history isn't worth keeping (the jbod-test drives). All
1059
+ # names are checked before anything is deleted. Touches no drive.
1060
+ def forget_drive(args)
1061
+ dry_run = false
1062
+ OptionParser.new { |o| o.on('--dry-run', 'Show what would be deleted') { dry_run = true } }.parse!(args)
1063
+ raise Error, "forget-drive needs at least one drive name\n\n#{USAGE}" if args.empty?
1064
+
1065
+ drives = args.map do |name|
1066
+ drive = manifest.drive_by_name(name) or raise Error, "no drive named #{name}"
1067
+ raise Error, "#{name} is not retired; retire it first with `replace-drive`" unless drive.retired?
1068
+ raise Error, "#{name} still holds folders" if manifest.folders_on(drive.serial_number).any?
1069
+
1070
+ drive
1071
+ end
1072
+ drives.each do |d|
1073
+ removed = dry_run ? manifest.drive_references(d.serial_number) : manifest.forget_drive(d.serial_number)
1074
+ rows = removed.map { |t, n| "#{n} #{t}" }.join(', ')
1075
+ @out.puts "#{dry_run ? 'Would forget' : 'Forgot'} #{d.friendly_name} (#{d.serial_number})" \
1076
+ "#{rows.empty? ? '' : " and its rows: #{rows}"}."
1077
+ end
1078
+ @out.puts 'Dry run: nothing deleted.' if dry_run
1079
+ end
1080
+
1081
+ # Manually confirms that a drive flagged 'warning' or 'degraded_stable'
1082
+ # over reallocated sectors is not actively getting worse - typically
1083
+ # after an independent full-surface scan (SpinRite etc.) found zero new
1084
+ # defects. Locks in the drive's most recently read reallocated-sector
1085
+ # count as the new baseline, so future checks compare against today,
1086
+ # not against whatever the count happened to be before this tool ever
1087
+ # tracked it.
1088
+ def verify_drive(args)
1089
+ opts = {}
1090
+ OptionParser.new { |o| o.on('--note TEXT', 'e.g. "SpinRite Level 3, 22.5h, 0 new defects"') { |v| opts[:note] = v } }.parse!(args)
1091
+ name = args.first or raise Error, "verify-drive needs a drive name\n\n#{USAGE}"
1092
+ drive = manifest.drive_by_name(name) or raise Error, "no drive named #{name}"
1093
+
1094
+ manifest.verify_drive_stable(drive.serial_number, note: opts[:note])
1095
+ if drive.smart_status == 'warning'
1096
+ manifest.update_drive_health(drive.serial_number, status: 'degraded_stable', detail: drive.smart_detail)
1097
+ end
1098
+ message = "Recorded #{name}'s current reallocated-sector count as a verified-stable checkpoint."
1099
+ message += " Note: #{opts[:note]}" if opts[:note]
1100
+ @out.puts message
1101
+ end
1102
+
1103
+ # Keeps the drive's own .easy_sync/drive.json readable-by-hand, though
1104
+ # nothing reads its friendly_name back: matching is by serial only.
1105
+ def relabel_marker(serial, new_name, mounted)
1106
+ m = mounted[serial] or return
1107
+ existing = volume_info.read_marker(m.mount_point)
1108
+ registered_at = existing&.fetch(:registered_at, nil) || Time.now.utc.iso8601
1109
+ volume_info.write_marker(m.mount_point, serial_number: serial, friendly_name: new_name, registered_at: registered_at)
1110
+ end
1111
+
1112
+ def rename_volume_hint(serial, new_name, mounted)
1113
+ m = mounted[serial] or return
1114
+
1115
+ @out.puts " #{m.mount_point} still has its old macOS volume name; to match, rename it yourself: " \
1116
+ "diskutil rename #{m.mount_point} #{new_name}"
1117
+ end
1118
+
1119
+ def dashboard
1120
+ @out.puts "Dashboard written to #{write_dashboard}"
1121
+ end
1122
+
1123
+ def write_dashboard
1124
+ mounted = volume_info.mounted_drives(manifest.drives)
1125
+ run = Jbod::RunLock.new(settings[:lock_path]).status
1126
+ Jbod::Dashboard.new(manifest, grace_days: settings[:grace_days], scrub_stale_days: settings[:scrub_stale_days],
1127
+ mount_root: settings[:mount_root])
1128
+ .write(settings[:dashboard_path], mounted: mounted, running: run)
1129
+ end
1130
+
1131
+ # Ejects every connected easy_sync drive (or just the ones named) so the
1132
+ # enclosure can be powered off between syncs. Refuses while a sync, scrub
1133
+ # or other run holds the lock, since ejecting would cut it off. Each drive
1134
+ # is marked last seen now, which starts the dashboard's Backblaze
1135
+ # 30-day countdown from the real disconnect rather than the last sync.
1136
+ # Returns 1 if any drive stayed mounted: the enclosure is not safe to
1137
+ # power off yet.
1138
+ def eject(args)
1139
+ opts = { dry_run: false, yes: false }
1140
+ OptionParser.new do |o|
1141
+ o.on('--dry-run', 'Show which drives would be ejected') { opts[:dry_run] = true }
1142
+ o.on('-y', '--yes', "Eject even if Backblaze hasn't finished uploading a drive") { opts[:yes] = true }
1143
+ end.parse!(args)
1144
+ drives = drives_to_eject(args)
1145
+ if drives.empty?
1146
+ @out.puts 'No easy_sync drive is connected; nothing to eject.'
1147
+ return 0
1148
+ end
1149
+ behind = backblaze_behind(drives)
1150
+ if opts[:dry_run]
1151
+ drives.each { |m| @out.puts "Would eject #{m.friendly_name} (#{m.mount_point})" }
1152
+ warn_backblaze_behind(behind)
1153
+ @out.puts 'Dry run: nothing ejected.'
1154
+ return 0
1155
+ end
1156
+
1157
+ lock = Jbod::RunLock.new(settings[:lock_path])
1158
+ if (run = lock.status)
1159
+ raise Error, "#{run.kind} is running (pid #{run.pid}); ejecting now would cut it off. " \
1160
+ 'Let it finish (or stop it with Ctrl-C), then eject.'
1161
+ end
1162
+ unless behind.empty?
1163
+ warn_backblaze_behind(behind)
1164
+ return 1 unless opts[:yes] || confirm_eject
1165
+ end
1166
+
1167
+ failed = lock.acquire(kind: 'eject') { drives.reject { |m| eject_one(m) } }
1168
+ write_dashboard
1169
+ report_ejected(drives.size - failed.size, failed)
1170
+ end
1171
+
1172
+ # [[MountedDrive, Backblaze::DriveState], ...] for the drives Backblaze
1173
+ # has not finished with. A drive Backblaze doesn't back up at all
1174
+ # (:unknown) is not "behind", and without Backblaze nothing is.
1175
+ def backblaze_behind(drives)
1176
+ backblaze = Jbod::Backblaze.read or return []
1177
+
1178
+ copied = manifest.last_copied_at
1179
+ drives.filter_map do |m|
1180
+ state = backblaze.drive_state(m.mount_point, copied[m.serial_number])
1181
+ [m, state] if %i[uploading waiting].include?(state.state)
1182
+ end
1183
+ end
1184
+
1185
+ def warn_backblaze_behind(behind)
1186
+ return if behind.empty?
1187
+
1188
+ @out.puts "Backblaze hasn't finished with #{behind.size == 1 ? 'this drive' : "these #{behind.size} drives"}:"
1189
+ behind.each { |m, state| @out.puts " #{m.friendly_name.ljust(16)} #{state.label}" }
1190
+ @out.puts 'Powered off, that data stays out of Backblaze until the drive is connected again.'
1191
+ end
1192
+
1193
+ # Asks before ejecting a drive Backblaze is still uploading; no (the
1194
+ # default) ejects nothing. Without a terminal to ask on it refuses, and
1195
+ # --yes is the way to say yes up front.
1196
+ def confirm_eject
1197
+ raise Error, 'not ejecting: no terminal to confirm on. Run it again with --yes to eject anyway.' unless @stdin.tty?
1198
+
1199
+ @out.print 'Eject anyway? [y/N] '
1200
+ @out.flush
1201
+ return true if @stdin.gets.to_s.strip.match?(/\Ay(es)?\z/i)
1202
+
1203
+ @out.puts 'Nothing ejected.'
1204
+ false
1205
+ end
1206
+
1207
+ def drives_to_eject(names)
1208
+ registered = manifest.drives(include_retired: true)
1209
+ mounted = volume_info.mounted_drives(registered)
1210
+ return mounted if names.empty?
1211
+
1212
+ names.map do |name|
1213
+ registered.find { |d| d.friendly_name == name } or raise Error, "no drive named #{name}"
1214
+ mounted.find { |m| m.friendly_name == name } or raise Error, "#{name} is not connected"
1215
+ end
1216
+ end
1217
+
1218
+ def eject_one(mounted)
1219
+ manifest.update_drive_usage(mounted.serial_number, used_bytes: mounted.used_bytes, free_bytes: mounted.free_bytes,
1220
+ capacity_bytes: mounted.capacity_bytes)
1221
+ result = volume_info.eject(mounted.mount_point)
1222
+ if result.ok
1223
+ @out.puts "Ejected #{mounted.friendly_name} (#{result.disk})"
1224
+ else
1225
+ reason = result.blocker ? "in use by pid #{result.blocker}" : result.message
1226
+ @out.puts "Could not eject #{mounted.friendly_name}: #{reason}"
1227
+ end
1228
+ result.ok
1229
+ end
1230
+
1231
+ def report_ejected(ejected, failed)
1232
+ unless failed.empty?
1233
+ @out.puts "#{failed.map(&:friendly_name).join(', ')} still connected: do not power off yet. " \
1234
+ 'Close whatever is using it and run `easy_sync eject` again.'
1235
+ return 1
1236
+ end
1237
+ @out.puts "#{ejected == 1 ? 'The drive is' : "All #{ejected} drives are"} ejected; it is safe to power off the enclosure."
1238
+ return 0 unless Jbod::Backblaze.read
1239
+
1240
+ back_by = (@clock.now + Jbod::Dashboard::BACKBLAZE_DROP_DAYS * 86_400).strftime('%Y-%m-%d')
1241
+ @out.puts "Connect again by #{back_by}: Backblaze drops a drive from its current backup after " \
1242
+ "#{Jbod::Dashboard::BACKBLAZE_DROP_DAYS} days disconnected."
1243
+ 0
1244
+ end
1245
+ end
1246
+ end