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.
- checksums.yaml +5 -5
- data/.gitignore +8 -0
- data/.rspec +2 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +85 -0
- data/CLAUDE.md +133 -0
- data/Gemfile +2 -0
- data/README.md +607 -21
- data/Rakefile +6 -8
- data/bin/easy_sync +8 -1
- data/docs/fine-placement.md +174 -0
- data/docs/hardware-notes.md +71 -0
- data/docs/integrity-scan.md +435 -0
- data/docs/parallel-scrub.md +280 -0
- data/docs/performance.md +146 -0
- data/docs/tripwire.md +232 -0
- data/easy_sync.gemspec +25 -16
- data/lib/easy_sync/cli.rb +1246 -0
- data/lib/easy_sync/config.rb +202 -0
- data/lib/easy_sync/error.rb +5 -0
- data/lib/easy_sync/jbod/backblaze.rb +113 -0
- data/lib/easy_sync/jbod/benchmarker.rb +149 -0
- data/lib/easy_sync/jbod/cleaner.rb +93 -0
- data/lib/easy_sync/jbod/dashboard.rb +611 -0
- data/lib/easy_sync/jbod/keep_awake.rb +33 -0
- data/lib/easy_sync/jbod/manifest.rb +1068 -0
- data/lib/easy_sync/jbod/mirror.rb +204 -0
- data/lib/easy_sync/jbod/models.rb +165 -0
- data/lib/easy_sync/jbod/page_cache.rb +56 -0
- data/lib/easy_sync/jbod/placement.rb +71 -0
- data/lib/easy_sync/jbod/planner.rb +76 -0
- data/lib/easy_sync/jbod/purger.rb +161 -0
- data/lib/easy_sync/jbod/restorer.rb +142 -0
- data/lib/easy_sync/jbod/run_lock.rb +98 -0
- data/lib/easy_sync/jbod/run_log.rb +57 -0
- data/lib/easy_sync/jbod/runner.rb +615 -0
- data/lib/easy_sync/jbod/scrub_pool.rb +136 -0
- data/lib/easy_sync/jbod/scrubber.rb +321 -0
- data/lib/easy_sync/jbod/share_scan.rb +26 -0
- data/lib/easy_sync/jbod/sync_eta.rb +68 -0
- data/lib/easy_sync/jbod/tripwire.rb +80 -0
- data/lib/easy_sync/jbod/volume_info.rb +283 -0
- data/lib/easy_sync/jbod.rb +35 -0
- data/lib/easy_sync/shell.rb +62 -0
- data/lib/easy_sync/templates/dashboard.html.erb +419 -0
- data/lib/easy_sync/version.rb +3 -1
- data/lib/easy_sync.rb +7 -3
- metadata +84 -45
- data/Gemfile.lock +0 -55
- data/Guardfile +0 -28
- data/lib/easy_sync/rsync.rb +0 -60
- data/lib/easy_sync/sync_logger.rb +0 -20
- data/lib/easy_sync/sync_runner.rb +0 -44
- data/spec/easy_sync/rsync_spec.rb +0 -25
- data/spec/easy_sync/sync_runner_spec.rb +0 -54
- data/spec/spec_helper.rb +0 -33
- 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
|