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