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