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,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module EasySync
|
|
6
|
+
module Jbod
|
|
7
|
+
# Removes expired deletion candidates from the drives. This is the only
|
|
8
|
+
# code in the tool that deletes backup data, so it is deliberately narrow:
|
|
9
|
+
# a candidate is removed only if its folder's drive is mounted right now,
|
|
10
|
+
# the path resolves inside that folder's destination, and it has been
|
|
11
|
+
# missing on the NAS for both the day and run thresholds.
|
|
12
|
+
class Purger
|
|
13
|
+
Result = Struct.new(:purged, :would_purge, :skipped, keyword_init: true) do
|
|
14
|
+
def initialize(**)
|
|
15
|
+
super
|
|
16
|
+
members.each { |m| self[m] ||= [] }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(manifest, grace_days:, grace_runs:, clock: Time, out: $stdout)
|
|
21
|
+
@manifest = manifest
|
|
22
|
+
@grace_days = grace_days
|
|
23
|
+
@grace_runs = grace_runs
|
|
24
|
+
@clock = clock
|
|
25
|
+
@out = out
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# +mounted+ are the MountedDrive structs for this run.
|
|
29
|
+
def run(mounted, dry_run: false)
|
|
30
|
+
by_serial = mounted.to_h { |m| [m.serial_number, m] }
|
|
31
|
+
result = Result.new
|
|
32
|
+
expired = @manifest.expired_deletions(now: @clock.now, grace_days: @grace_days, grace_runs: @grace_runs)
|
|
33
|
+
.select { |p| ready?(p) }
|
|
34
|
+
|
|
35
|
+
# Whole folders first; their file-level candidates go with them.
|
|
36
|
+
folders, paths = expired.partition(&:whole_folder?)
|
|
37
|
+
folders.each do |pending|
|
|
38
|
+
root = @manifest.folder(pending.folder_path)&.root?
|
|
39
|
+
purge_one(pending, by_serial, result, dry_run: dry_run, root: root) do |dest, drive|
|
|
40
|
+
# A root unit is only a share's loose top-level files; the
|
|
41
|
+
# share's subfolders below dest belong to other folders.
|
|
42
|
+
root ? remove_top_level_files(dest) : FileUtils.rm_rf(dest)
|
|
43
|
+
remove_empty_dirs(root ? dest : File.dirname(dest), drive.mount_point)
|
|
44
|
+
# A 'reassigned' candidate is the OLD copy of a folder that still
|
|
45
|
+
# has a valid manifest row elsewhere (see #ready?); only the
|
|
46
|
+
# files come out, the folder record itself must survive. A
|
|
47
|
+
# 'missing_on_nas' candidate means the folder is gone entirely.
|
|
48
|
+
unless pending.reassigned?
|
|
49
|
+
@manifest.clear_pending(pending.folder_path)
|
|
50
|
+
@manifest.remove_folder(pending.folder_path, note: "deleted from #{drive.friendly_name}: " \
|
|
51
|
+
"missing on NAS since #{pending.first_missing_at}")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
gone = folders.map(&:folder_path)
|
|
57
|
+
files, dirs = paths.reject { |p| gone.include?(p.folder_path) }.partition { |p| p.kind == 'file' }
|
|
58
|
+
files.each do |pending|
|
|
59
|
+
purge_one(pending, by_serial, result, dry_run: dry_run) { |dest, _| File.delete(dest) if File.exist?(dest) }
|
|
60
|
+
end
|
|
61
|
+
# Deepest directories first so parents empty out before we reach them.
|
|
62
|
+
dirs.sort_by { |p| -p.relative_path.count('/') }.each do |pending|
|
|
63
|
+
purge_one(pending, by_serial, result, dry_run: dry_run) do |dest, _|
|
|
64
|
+
if Dir.exist?(dest) && !Dir.empty?(dest)
|
|
65
|
+
@out.puts " leaving #{dest}: not empty yet"
|
|
66
|
+
next false
|
|
67
|
+
end
|
|
68
|
+
Dir.rmdir(dest) if Dir.exist?(dest)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
result
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# A folder moved off a drive (cause 'reassigned') is only safe to purge
|
|
77
|
+
# from there once it has actually landed, verified, on its new drive:
|
|
78
|
+
# grace_days alone exists to protect against a flaky NAS probe and says
|
|
79
|
+
# nothing about whether the fresh copy elsewhere actually succeeded.
|
|
80
|
+
def ready?(pending)
|
|
81
|
+
return true unless pending.reassigned?
|
|
82
|
+
|
|
83
|
+
current = @manifest.folder(pending.folder_path)
|
|
84
|
+
!current.nil? && current.drive_serial != pending.drive_serial && current.last_sync_status == 'ok'
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def purge_one(pending, by_serial, result, dry_run:, root: false)
|
|
88
|
+
drive = by_serial[pending.drive_serial]
|
|
89
|
+
unless drive
|
|
90
|
+
result.skipped << [pending, 'drive not mounted']
|
|
91
|
+
return
|
|
92
|
+
end
|
|
93
|
+
if pending.whole_folder? && !root && (live = overlapping_live_folder(pending, drive.serial_number))
|
|
94
|
+
result.skipped << [pending, "#{live} is a live folder that overlaps it on #{drive.friendly_name}; " \
|
|
95
|
+
'not deleting (resolve by hand)']
|
|
96
|
+
return
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
base = File.join(drive.mount_point, pending.folder_path)
|
|
100
|
+
dest = pending.whole_folder? ? base : File.join(base, pending.relative_path)
|
|
101
|
+
unless inside?(dest, base) && File.expand_path(base).start_with?("#{File.expand_path(drive.mount_point)}/")
|
|
102
|
+
result.skipped << [pending, 'path escapes its folder']
|
|
103
|
+
return
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
label = pending.whole_folder? ? "#{pending.folder_path} (whole folder)" : "#{pending.folder_path}/#{pending.relative_path}"
|
|
107
|
+
if dry_run
|
|
108
|
+
@out.puts " would delete #{label} from #{drive.friendly_name}"
|
|
109
|
+
result.would_purge << [pending, drive.friendly_name]
|
|
110
|
+
return
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
@out.puts " deleting #{label} from #{drive.friendly_name}"
|
|
114
|
+
return if yield(dest, drive) == false
|
|
115
|
+
|
|
116
|
+
@manifest.record_deletion(pending, drive_serial: drive.serial_number)
|
|
117
|
+
result.purged << [pending, drive.friendly_name]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Another folder placed on the same drive that deleting this whole
|
|
121
|
+
# folder would take with it (it lives below the path), or whose tree
|
|
122
|
+
# this path is part of. Either means the manifest changed shape after
|
|
123
|
+
# the candidate was recorded, and an rm_rf would destroy live data.
|
|
124
|
+
def overlapping_live_folder(pending, serial)
|
|
125
|
+
prefix = "#{pending.folder_path}/"
|
|
126
|
+
@manifest.folders_on(serial).find do |f|
|
|
127
|
+
next false if f.folder_path == pending.folder_path
|
|
128
|
+
|
|
129
|
+
f.folder_path.start_with?(prefix) || (!f.root? && pending.folder_path.start_with?("#{f.folder_path}/"))
|
|
130
|
+
end&.folder_path
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The share directory a removed folder sat in (and the root unit's own
|
|
134
|
+
# directory) is removed once nothing is left in it, so a share that
|
|
135
|
+
# has left a drive entirely leaves no empty shell behind. Never goes
|
|
136
|
+
# above the share level, and never touches the drive root.
|
|
137
|
+
def remove_empty_dirs(dir, mount_point)
|
|
138
|
+
top = File.expand_path(mount_point)
|
|
139
|
+
dir = File.expand_path(dir)
|
|
140
|
+
while dir.start_with?("#{top}/") && Dir.exist?(dir) && Dir.empty?(dir)
|
|
141
|
+
Dir.rmdir(dir)
|
|
142
|
+
dir = File.dirname(dir)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def remove_top_level_files(dir)
|
|
147
|
+
return unless Dir.exist?(dir)
|
|
148
|
+
|
|
149
|
+
Dir.children(dir).each do |name|
|
|
150
|
+
path = File.join(dir, name)
|
|
151
|
+
File.delete(path) if File.file?(path) || File.symlink?(path)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def inside?(path, base)
|
|
156
|
+
expanded = File.expand_path(path)
|
|
157
|
+
expanded == File.expand_path(base) || expanded.start_with?("#{File.expand_path(base)}/")
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module EasySync
|
|
6
|
+
module Jbod
|
|
7
|
+
# `easy_sync restore`: the reverse of `sync`. Copies a folder (or every
|
|
8
|
+
# folder under a share, or everything) from wherever the manifest says it
|
|
9
|
+
# currently lives back onto its NAS share. For rebuilding a NAS after a
|
|
10
|
+
# wipe/reformat, the scenario the old single-drive drobo-sync restore
|
|
11
|
+
# scripts covered by hand; here the folders for one share can be spread
|
|
12
|
+
# across several drives, so this walks the manifest instead of assuming
|
|
13
|
+
# one source directory.
|
|
14
|
+
#
|
|
15
|
+
# Never deletes, exactly like those old scripts: only adds and updates
|
|
16
|
+
# files on the NAS. A drive with fewer files than the NAS already has
|
|
17
|
+
# leaves the extra NAS files alone.
|
|
18
|
+
class Restorer
|
|
19
|
+
class UnknownTarget < Error; end
|
|
20
|
+
|
|
21
|
+
Result = Struct.new(:restored, :skipped, :failed, keyword_init: true) do
|
|
22
|
+
def initialize(**)
|
|
23
|
+
super
|
|
24
|
+
members.each { |m| self[m] ||= [] }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(settings, manifest:, shell: Shell.new, out: $stdout)
|
|
29
|
+
@manifest = manifest
|
|
30
|
+
@shell = shell
|
|
31
|
+
@out = out
|
|
32
|
+
@excludes = Array(settings[:exclude_folders]).map { |e| "--exclude=#{e}" }
|
|
33
|
+
@share_paths = Array(settings[:sources]).to_h do |e|
|
|
34
|
+
path = e.is_a?(Hash) ? e[:path] : e.to_s
|
|
35
|
+
[File.basename(path), path]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# +targets+ are folder_paths ("tv/Breaking Bad") or share names ("tv"),
|
|
40
|
+
# expanded to every folder currently in the manifest under that share
|
|
41
|
+
# (including the share's root-files unit, which is keyed by its name).
|
|
42
|
+
# Raises if a name matches nothing placed.
|
|
43
|
+
def resolve(targets)
|
|
44
|
+
all = @manifest.folders
|
|
45
|
+
targets.flat_map do |t|
|
|
46
|
+
exact = all.find { |f| f.folder_path == t }
|
|
47
|
+
next [exact] if exact && !exact.root?
|
|
48
|
+
|
|
49
|
+
under_share = [exact].compact + all.select { |f| f.folder_path.start_with?("#{t}/") }
|
|
50
|
+
raise UnknownTarget, "#{t} matches no placed folder or share" if under_share.empty?
|
|
51
|
+
|
|
52
|
+
under_share
|
|
53
|
+
end.uniq(&:folder_path)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# +folders+ are Folder structs from the manifest; +mounted+ are this
|
|
57
|
+
# run's MountedDrive structs. Returns a Result.
|
|
58
|
+
def run(folders, mounted, dry_run: false)
|
|
59
|
+
result = Result.new
|
|
60
|
+
by_serial = mounted.to_h { |m| [m.serial_number, m] }
|
|
61
|
+
folders.each { |folder| restore_one(folder, by_serial, result, dry_run: dry_run) }
|
|
62
|
+
result
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# A rotted file must not quietly go back to the NAS: warn and name
|
|
68
|
+
# every file `scrub` has flagged for this folder on its current drive,
|
|
69
|
+
# whatever its state (awaiting refetch, refetched, or unresolved).
|
|
70
|
+
def warn_about_flagged_files(folder, drive)
|
|
71
|
+
flagged = @manifest.scrub_findings_for(drive.serial_number, folder.folder_path)
|
|
72
|
+
return if flagged.empty?
|
|
73
|
+
|
|
74
|
+
@out.puts "WARNING: #{folder.folder_path}: #{flagged.size} file#{'s' if flagged.size != 1} on #{drive.friendly_name} " \
|
|
75
|
+
"#{flagged.size == 1 ? 'is' : 'are'} flagged by scrub (#{flagged.map { |f| "#{f.relative_path}: #{f.status}" }.join(', ')}); " \
|
|
76
|
+
'restoring may copy a rotted file onto the NAS.'
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def restore_one(folder, by_serial, result, dry_run:)
|
|
80
|
+
drive = by_serial[folder.drive_serial]
|
|
81
|
+
if drive.nil?
|
|
82
|
+
name = @manifest.drive(folder.drive_serial)&.friendly_name || folder.drive_serial
|
|
83
|
+
return skip(result, folder.folder_path, "its drive #{name} is not mounted")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
root = share_root(folder.folder_path)
|
|
87
|
+
if root.nil?
|
|
88
|
+
share = folder.folder_path.split('/').first
|
|
89
|
+
return skip(result, folder.folder_path, "#{share} is not a configured source; `add-source` it first")
|
|
90
|
+
end
|
|
91
|
+
return skip(result, folder.folder_path, "the NAS share is not mounted at #{root}") unless Dir.exist?(root)
|
|
92
|
+
|
|
93
|
+
destination = nas_destination(folder.folder_path)
|
|
94
|
+
|
|
95
|
+
source = File.join(drive.mount_point, folder.folder_path)
|
|
96
|
+
return skip(result, folder.folder_path, "nothing at #{source} on #{drive.friendly_name}") unless Dir.exist?(source)
|
|
97
|
+
|
|
98
|
+
warn_about_flagged_files(folder, drive)
|
|
99
|
+
@out.puts "\n------------------ #{folder.folder_path}: #{drive.friendly_name} -> NAS ------------------"
|
|
100
|
+
rsync_result = restore_copy(source, destination, dry_run: dry_run, root_only: folder.root?)
|
|
101
|
+
if rsync_result.success?
|
|
102
|
+
result.restored << folder.folder_path
|
|
103
|
+
else
|
|
104
|
+
@out.puts "WARNING: rsync for #{folder.folder_path} exited with status #{rsync_result.status}"
|
|
105
|
+
result.failed << folder.folder_path
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# No --delete, ever: restoring must never remove anything already on the NAS.
|
|
110
|
+
# A root-files unit restores only its top-level files; the share's
|
|
111
|
+
# subfolders are folders of their own, possibly on other drives.
|
|
112
|
+
def restore_copy(source, destination, dry_run:, root_only: false)
|
|
113
|
+
FileUtils.mkdir_p(File.dirname(destination)) unless dry_run
|
|
114
|
+
argv = ['rsync', '-a', '--partial', '--stats', '--info=progress2', '--itemize-changes', *@excludes]
|
|
115
|
+
argv << Mirror::ROOT_ONLY if root_only
|
|
116
|
+
argv << '--dry-run' if dry_run
|
|
117
|
+
argv += [with_slash(source), with_slash(destination)]
|
|
118
|
+
@shell.run(argv)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def share_root(folder_path)
|
|
122
|
+
@share_paths[folder_path.split('/').first]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def nas_destination(folder_path)
|
|
126
|
+
share, rest = folder_path.split('/', 2)
|
|
127
|
+
root = @share_paths[share] or return nil
|
|
128
|
+
|
|
129
|
+
rest ? File.join(root, rest) : root
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def skip(result, folder_path, why)
|
|
133
|
+
@out.puts "WARNING: skipping #{folder_path}: #{why}"
|
|
134
|
+
result.skipped << folder_path
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def with_slash(path)
|
|
138
|
+
path.end_with?('/') ? path : "#{path}/"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module EasySync
|
|
6
|
+
module Jbod
|
|
7
|
+
# Prevents two long-running commands (sync, scrub, clean, restore) from
|
|
8
|
+
# overlapping: doubled NAS/drive load, and two processes racing over the
|
|
9
|
+
# same state. A PID file at +path+ is the lock; a stale one (its process
|
|
10
|
+
# no longer running) is reclaimed automatically rather than blocking
|
|
11
|
+
# forever. The file's second line names which command holds it, so
|
|
12
|
+
# `status`/the dashboard can say "Scrub running" instead of assuming sync;
|
|
13
|
+
# a lock file with no second line (written before this existed) reads as
|
|
14
|
+
# 'sync', its original and only kind. Every line from the third on is free
|
|
15
|
+
# text the holder can update as it goes (Jbod::ScrubPool uses one line per
|
|
16
|
+
# drive its workers are currently on) without disturbing the mtime that
|
|
17
|
+
# #status reads as the run's start time.
|
|
18
|
+
class RunLock
|
|
19
|
+
class AlreadyRunning < Error; end
|
|
20
|
+
|
|
21
|
+
# pid: the process holding the lock. started_at: the lock file's mtime,
|
|
22
|
+
# which is set once when it's written and never touched again for the
|
|
23
|
+
# life of the run, so it doubles as the run's start time. kind: 'sync',
|
|
24
|
+
# 'scrub', 'clean', or 'restore'. current: an Array of the holder's
|
|
25
|
+
# free-form progress notes (see #note), empty if it hasn't set any.
|
|
26
|
+
Status = Struct.new(:pid, :started_at, :kind, :current, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
def initialize(path)
|
|
29
|
+
@path = path
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Runs the block while holding the lock. Raises AlreadyRunning instead of
|
|
33
|
+
# running the block if another live process already holds it.
|
|
34
|
+
def acquire(kind: 'sync')
|
|
35
|
+
if (pid = holder)
|
|
36
|
+
raise AlreadyRunning, "another easy_sync run is already running (pid #{pid}). " \
|
|
37
|
+
"If that's wrong (the process really is gone), remove #{@path}."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
41
|
+
File.write(@path, "#{Process.pid}\n#{kind}\n")
|
|
42
|
+
begin
|
|
43
|
+
yield
|
|
44
|
+
ensure
|
|
45
|
+
File.delete(@path) if File.exist?(@path) && pid_in_file == Process.pid
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Replaces the free-form lines from the third on (one per +names+, empty
|
|
50
|
+
# to clear them) without touching the file's mtime (the run's recorded
|
|
51
|
+
# start time) or releasing the lock. A no-op unless this process is the
|
|
52
|
+
# one actually holding it.
|
|
53
|
+
def note(*names)
|
|
54
|
+
return unless File.exist?(@path) && pid_in_file == Process.pid
|
|
55
|
+
|
|
56
|
+
mtime = File.mtime(@path)
|
|
57
|
+
File.write(@path, "#{[Process.pid, kind_in_file, *names].join("\n")}\n")
|
|
58
|
+
File.utime(mtime, mtime, @path)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The run currently holding the lock, or nil if none is (a stale lock
|
|
62
|
+
# left by a dead process counts as none).
|
|
63
|
+
def status
|
|
64
|
+
pid = holder or return nil
|
|
65
|
+
|
|
66
|
+
Status.new(pid: pid, started_at: File.mtime(@path), kind: kind_in_file, current: current_in_file)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# The PID holding the lock, or nil if there is none or it's stale.
|
|
72
|
+
def holder
|
|
73
|
+
pid = pid_in_file
|
|
74
|
+
return nil if pid.nil? || pid.zero?
|
|
75
|
+
|
|
76
|
+
Process.kill(0, pid)
|
|
77
|
+
pid
|
|
78
|
+
rescue Errno::ESRCH
|
|
79
|
+
nil # no such process: the lock is stale
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def pid_in_file
|
|
83
|
+
return nil unless File.exist?(@path)
|
|
84
|
+
|
|
85
|
+
File.read(@path).lines.first.to_s.strip.to_i
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def kind_in_file
|
|
89
|
+
line = File.read(@path).lines[1]
|
|
90
|
+
line ? line.strip : 'sync'
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def current_in_file
|
|
94
|
+
File.read(@path).lines.drop(2).map(&:strip).reject(&:empty?)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module EasySync
|
|
7
|
+
module Jbod
|
|
8
|
+
# Tees everything a sync prints into ~/.easy_sync/logs/sync-<timestamp>.log
|
|
9
|
+
# as it happens, so a multi-day run has a record even if the terminal is
|
|
10
|
+
# gone. rsync's in-place progress chunks (carriage-return updates) are kept
|
|
11
|
+
# off disk; the per-folder --stats block that follows them has the totals.
|
|
12
|
+
class RunLog
|
|
13
|
+
attr_reader :path
|
|
14
|
+
|
|
15
|
+
# +prefix+ names the run kind ('sync' or 'scrub'), so each is pruned
|
|
16
|
+
# separately: scrub-*.log files never push out sync-*.log files, or the
|
|
17
|
+
# other way around.
|
|
18
|
+
def self.open(dir, keep: 20, out: $stdout, clock: Time, prefix: 'sync')
|
|
19
|
+
FileUtils.mkdir_p(dir)
|
|
20
|
+
path = File.join(dir, "#{prefix}-#{clock.now.strftime('%Y%m%d-%H%M%S')}.log")
|
|
21
|
+
prune(dir, keep: keep - 1, prefix: prefix)
|
|
22
|
+
new(File.open(path, 'a'), out: out, path: path)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Keeps the newest +keep+ logs matching +prefix+ in +dir+.
|
|
26
|
+
def self.prune(dir, keep:, prefix: 'sync')
|
|
27
|
+
logs = Dir.glob(File.join(dir, "#{prefix}-*.log")).sort
|
|
28
|
+
(logs.size - [keep, 0].max).clamp(0, logs.size).times { |i| File.delete(logs[i]) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(file, out:, path: nil)
|
|
32
|
+
@file = file
|
|
33
|
+
@out = out
|
|
34
|
+
@path = path
|
|
35
|
+
@file.sync = true
|
|
36
|
+
@mutex = Mutex.new # Jbod::ScrubPool's workers all write through the same RunLog
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def puts(*lines)
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
@out.puts(*lines)
|
|
42
|
+
lines = [''] if lines.empty?
|
|
43
|
+
lines.flatten.each { |l| @file.puts(l) unless l.to_s.include?("\r") }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def print(*args)
|
|
48
|
+
@mutex.synchronize do
|
|
49
|
+
@out.print(*args)
|
|
50
|
+
@file.print(*args) unless args.join.include?("\r")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def close = @file.close
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|