bitferry 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (6) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +233 -0
  3. data/bin/bitferry +3 -0
  4. data/lib/bitferry/cli.rb +344 -0
  5. data/lib/bitferry.rb +1370 -0
  6. metadata +104 -0
data/lib/bitferry.rb ADDED
@@ -0,0 +1,1370 @@
1
+ require 'json'
2
+ require 'date'
3
+ require 'open3'
4
+ require 'logger'
5
+ require 'pathname'
6
+ require 'neatjson'
7
+ require 'rbconfig'
8
+ require 'fileutils'
9
+ require 'shellwords'
10
+
11
+
12
+ module Bitferry
13
+
14
+
15
+ VERSION = '0.0.1'
16
+
17
+
18
+ # :nodoc:
19
+ module Logging
20
+ def self.log
21
+ unless @log
22
+ @log = Logger.new($stderr)
23
+ @log.level = Logger::WARN
24
+ @log.progname = :bitferry
25
+ end
26
+ @log
27
+ end
28
+ def log = Logging.log
29
+ end
30
+
31
+
32
+ include Logging
33
+ extend Logging
34
+
35
+
36
+ def self.tag = format('%08x', 2**32*rand)
37
+
38
+
39
+ def self.restore
40
+ reset
41
+ log.info('restoring volumes')
42
+ result = true
43
+ roots = (environment_mounts + system_mounts).uniq
44
+ log.info("distilled volume search path: #{roots.join(', ')}")
45
+ roots.each do |root|
46
+ if File.exist?(File.join(root, Volume::STORAGE))
47
+ log.info("trying to restore volume from #{root}")
48
+ Volume.restore(root) rescue result = false
49
+ end
50
+ end
51
+ if result
52
+ log.info('volumes restored')
53
+ else
54
+ log.warn('volume restore failure(s) reported')
55
+ end
56
+ result
57
+ end
58
+
59
+
60
+ def self.commit
61
+ log.info('committing changes')
62
+ result = true
63
+ modified = false
64
+ Volume.registered.each do |volume|
65
+ begin
66
+ modified = true if volume.modified?
67
+ volume.commit
68
+ rescue IOError => e
69
+ log.error(e.message)
70
+ result = false
71
+ end
72
+ end
73
+ if result
74
+ log.info(modified ? 'changes committed' : 'commits skipped (no changes)')
75
+ else
76
+ log.warn('commit failure(s) reported')
77
+ end
78
+ result
79
+ end
80
+
81
+
82
+ def self.reset
83
+ log.info('resetting state')
84
+ Volume.reset
85
+ Task.reset
86
+ end
87
+
88
+
89
+ def self.process(*tags)
90
+ log.info('processing tasks')
91
+ tasks = Volume.intact.collect { |volume| volume.intact_tasks }.flatten.uniq
92
+ if tags.empty?
93
+ process = tasks
94
+ else
95
+ process = []
96
+ tags.each do |tag|
97
+ case (tasks = Task.match([tag], tasks)).size
98
+ when 0 then log.warn("no tasks matching (partial) tag #{tag}")
99
+ when 1 then process += tasks
100
+ else
101
+ tags = tasks.collect { |v| v.tag }.join(', ')
102
+ raise ArgumentError, "multiple tasks matching (partial) tag #{tag}: #{tags}"
103
+ end
104
+ end
105
+ end
106
+ result = process.uniq.all? { |task| task.process }
107
+ result ? log.info('tasks processed') : log.warn('task process failure(s) reported')
108
+ result
109
+ end
110
+
111
+
112
+ def self.endpoint(root)
113
+ case root
114
+ when /^:(\w+):(.*)/
115
+ volumes = Volume.lookup($1)
116
+ volume = case volumes.size
117
+ when 0 then raise ArgumentError, "no intact volume matching (partial) tag #{$1}"
118
+ when 1 then volumes.first
119
+ else
120
+ tags = volumes.collect { |v| v.tag }.join(', ')
121
+ raise ArgumentError, "multiple intact volumes matching (partial) tag #{$1}: #{tags}"
122
+ end
123
+ Endpoint::Bitferry.new(volume, $2)
124
+ when /^(?:local)?:(.*)/ then Endpoint::Local.new($1)
125
+ when /^(\w{2,}):(.*)/ then Endpoint::Rclone.new($1, $2)
126
+ else Volume.endpoint(root)
127
+ end
128
+ end
129
+
130
+
131
+ @simulate = false
132
+ def self.simulate? = @simulate
133
+ def self.simulate=(mode) @simulate = mode end
134
+
135
+
136
+ @verbosity = :default
137
+ def self.verbosity = @verbosity
138
+ def self.verbosity=(mode) @verbosity = mode end
139
+
140
+
141
+ # Return true if run in the real Windows environment (e.g. not in real *NIX or various emulation layers such as MSYS, Cygwin etc.)
142
+ def self.windows?
143
+ @windows ||= /^(mingw)/.match?(RbConfig::CONFIG['target_os']) # RubyInstaller's MRI, other MinGW-build MRI
144
+ end
145
+
146
+ # Return list of live user-provided mounts (mount points on *NIX and disk drives on Windows) which may contain Bitferry volumes
147
+ # Look for the $BITFERRY_PATH environment variable
148
+ def self.environment_mounts
149
+ ENV['BITFERRY_PATH'].split(PATH_LIST_SEPARATOR).collect { |path| File.directory?(path) ? path : nil }.compact rescue []
150
+ end
151
+
152
+
153
+ # Specify OS-specific path name list separator (such as in the $PATH environment variable)
154
+ PATH_LIST_SEPARATOR = windows? ? ';' : ':'
155
+
156
+
157
+ # Match OS-specific system mount points (/dev /proc etc.) which normally should be omitted when scanning for Bitferry voulmes
158
+ UNIX_SYSTEM_MOUNTS = %r!^/(dev|sys|proc|efi)!
159
+
160
+
161
+ # Return list of live system-managed mounts (mount points on *NIX and disk drives on Windows) which may contain Bitferry volumes
162
+ if RUBY_PLATFORM =~ /java/
163
+ require 'java'
164
+ def self.system_mounts
165
+ java.nio.file.FileSystems.getDefault.getFileStores.collect {|x| /^(.*)\s+\(.*\)$/.match(x.to_s)[1]}
166
+ end
167
+ else
168
+ case RbConfig::CONFIG['target_os']
169
+ when 'linux'
170
+ # Linux OS
171
+ def self.system_mounts
172
+ # Query /proc for currently mounted file systems
173
+ IO.readlines('/proc/mounts').collect do |line|
174
+ mount = line.split[1]
175
+ UNIX_SYSTEM_MOUNTS.match?(mount) || !File.directory?(mount) ? nil : mount
176
+ end.compact
177
+ end
178
+ # TODO handle Windows variants
179
+ when /^mingw/ # RubyInstaller's MRI
180
+ module Kernel32
181
+ require 'fiddle'
182
+ require 'fiddle/types'
183
+ require 'fiddle/import'
184
+ extend Fiddle::Importer
185
+ dlload('kernel32')
186
+ include Fiddle::Win32Types
187
+ extern 'DWORD WINAPI GetLogicalDrives()'
188
+ end
189
+ def self.system_mounts
190
+ mounts = []
191
+ mask = Kernel32.GetLogicalDrives
192
+ ('A'..'Z').each do |x|
193
+ mounts << "#{x}:/" if mask & 1 == 1
194
+ mask >>= 1
195
+ end
196
+ mounts
197
+ end
198
+ else
199
+ # Generic *NIX-like OS, including Cygwin & MSYS2
200
+ def self.system_mounts
201
+ # Use $(mount) system utility to obtain currently mounted file systems
202
+ %x(mount).split("\n").collect do |line|
203
+ mount = line.split[2]
204
+ UNIX_SYSTEM_MOUNTS.match?(mount) || !File.directory?(mount) ? nil : mount
205
+ end.compact
206
+ end
207
+ end
208
+ end
209
+
210
+
211
+ class Volume
212
+
213
+
214
+ include Logging
215
+ extend Logging
216
+
217
+
218
+ STORAGE = '.bitferry'
219
+ STORAGE_ = '.bitferry~'
220
+ STORAGE_MASK = '.bitferry*'
221
+
222
+
223
+ attr_reader :tag
224
+
225
+
226
+ attr_reader :generation
227
+
228
+
229
+ attr_reader :root
230
+
231
+
232
+ attr_reader :vault
233
+
234
+
235
+ def self.[](tag)
236
+ @@registry.each_value { |volume| return volume if volume.tag == tag }
237
+ nil
238
+ end
239
+
240
+
241
+ # Return list of registered volumes whose tags match at least one specified partial
242
+ def self.lookup(*tags) = match(tags, registered)
243
+
244
+
245
+ def self.match(tags, volumes)
246
+ rxs = tags.collect { |x| Regexp.new(x) }
247
+ volumes.filter do |volume|
248
+ rxs.any? { |rx| !(rx =~ volume.tag).nil? }
249
+ end
250
+ end
251
+
252
+
253
+ def self.new(root, **opts)
254
+ volume = allocate
255
+ volume.send(:create, root, **opts)
256
+ register(volume)
257
+ end
258
+
259
+
260
+ def self.restore(root)
261
+ begin
262
+ volume = allocate
263
+ volume.send(:restore, root)
264
+ volume = register(volume)
265
+ log.info("restored volume #{volume.tag} from #{root}")
266
+ volume
267
+ rescue => e
268
+ log.error("failed to restore volume from #{root}")
269
+ log.error(e.message) if $DEBUG
270
+ raise
271
+ end
272
+ end
273
+
274
+
275
+ def self.delete(*tags, wipe: false)
276
+ process = []
277
+ tags.each do |tag|
278
+ case (volumes = Volume.lookup(tag)).size
279
+ when 0 then log.warn("no volumes matching (partial) tag #{tag}")
280
+ when 1 then process += volumes
281
+ else
282
+ tags = volumes.collect { |v| v.tag }.join(', ')
283
+ raise ArgumentError, "multiple volumes matching (partial) tag #{tag}: #{tags}"
284
+ end
285
+ end
286
+ process.each { |volume| volume.delete(wipe: wipe) }
287
+ end
288
+
289
+
290
+ def initialize(root, tag: Bitferry.tag, modified: DateTime.now, overwrite: false)
291
+ @tag = tag
292
+ @generation = 0
293
+ @vault = {}
294
+ @modified = modified
295
+ @overwrite = overwrite
296
+ @root = Pathname.new(root).realdirpath
297
+ end
298
+
299
+
300
+ def create(*args, **opts)
301
+ initialize(*args, **opts)
302
+ @state = :pristine
303
+ @modified = true
304
+ end
305
+
306
+
307
+ def restore(root)
308
+ hash = JSON.load_file(storage = Pathname(root).join(STORAGE), { symbolize_names: true })
309
+ raise IOError, "bad volume storage #{storage}" unless hash.fetch(:bitferry) == "0"
310
+ initialize(root, tag: hash.fetch(:volume), modified: DateTime.parse(hash.fetch(:modified)))
311
+ hash.fetch(:tasks, []).each { |hash| Task::ROUTE.fetch(hash.fetch(:operation).intern).restore(hash) }
312
+ @vault = hash.fetch(:vault, {}).transform_keys { |key| key.to_s }
313
+ @state = :intact
314
+ @modified = false
315
+ end
316
+
317
+
318
+ def storage = @storage ||= root.join(STORAGE)
319
+ def storage_ = @storage_ ||= root.join(STORAGE_)
320
+
321
+
322
+ def commit
323
+ if modified?
324
+ log.info("commit volume #{tag} (modified)")
325
+ case @state
326
+ when :pristine
327
+ format
328
+ store
329
+ when :intact
330
+ store
331
+ when :removing
332
+ remove
333
+ else
334
+ raise
335
+ end
336
+ committed
337
+ else
338
+ log.info("skipped committing volume #{tag} (unmodified)")
339
+ end
340
+ end
341
+
342
+
343
+ def self.endpoint(root)
344
+ path = Pathname.new(root).realdirpath
345
+ # FIXME select innermost or outermost volume in case of nested volumes?
346
+ intact.sort { |v1, v2| v2.root.size <=> v1.root.size }.each do |volume|
347
+ begin
348
+ # FIXME chop trailing slashes
349
+ stem = path.relative_path_from(volume.root)
350
+ case stem.to_s
351
+ when '.' then return volume.endpoint
352
+ when /^[^\.].*/ then return volume.endpoint(stem)
353
+ end
354
+ rescue ArgumentError
355
+ # Catch different prefix error on Windows
356
+ end
357
+ end
358
+ raise ArgumentError, "no intact volume encompasses path #{root}"
359
+ end
360
+
361
+
362
+ def endpoint(path = String.new) = Endpoint::Bitferry.new(self, path)
363
+
364
+
365
+ def modified? = @modified || tasks.any? { |t| t.generation > generation }
366
+
367
+
368
+ def intact? = @state != :removing
369
+
370
+
371
+ def touch
372
+ x = tasks.collect { |t| t.generation }.max
373
+ @generation = x ? x + 1 : 0
374
+ @modified = true
375
+ end
376
+
377
+
378
+ def delete(wipe: false)
379
+ touch
380
+ @wipe = wipe
381
+ @state = :removing
382
+ log.info("marked volume #{tag} for deletion")
383
+ end
384
+
385
+
386
+ def committed
387
+ x = tasks.collect { |t| t.generation }.min
388
+ @generation = x ? x : 0
389
+ @modified = false
390
+ end
391
+
392
+
393
+ def store
394
+ tasks.each(&:commit)
395
+ hash = JSON.neat_generate(externalize, short: false, wrap: 200, afterColon: 1, afterComma: 1)
396
+ if Bitferry.simulate?
397
+ log.info("skipped volume #{tag} storage modification (simulation)")
398
+ else
399
+ begin
400
+ File.write(storage_, hash)
401
+ FileUtils.mv(storage_, storage)
402
+ log.info("written volume #{tag} storage #{storage}")
403
+ ensure
404
+ FileUtils.rm_f(storage_)
405
+ end
406
+ end
407
+ @state = :intact
408
+ end
409
+
410
+
411
+ def format
412
+ raise IOError, "refuse to overwrite existing volume storage #{storage}" if !@overwrite && File.exist?(storage)
413
+ if Bitferry.simulate?
414
+ log.info("skipped storage formatting (simulation)")
415
+ else
416
+ FileUtils.mkdir_p(root)
417
+ FileUtils.rm_f [storage, storage_]
418
+ log.info("formatted volume #{tag} in #{root}")
419
+ end
420
+ @state = nil
421
+ end
422
+
423
+
424
+ def remove
425
+ unless Bitferry.simulate?
426
+ if @wipe
427
+ FileUtils.rm_rf(Dir[File.join(root, '*'), File.join(root, '.*')])
428
+ log.info("wiped entire volume directory #{root}")
429
+ else
430
+ FileUtils.rm_f [storage, storage_]
431
+ log.info("deleted volume #{tag} storage files #{File.join(root, STORAGE_MASK)}")
432
+ end
433
+ end
434
+ @@registry.delete(root)
435
+ @state = nil
436
+ end
437
+
438
+
439
+ def externalize
440
+ tasks = live_tasks
441
+ v = vault.filter { |t| !Task[t].nil? && Task[t].live? } # Purge entries from non-existing (deleted) tasks
442
+ {
443
+ bitferry: "0",
444
+ volume: tag,
445
+ modified: (@modified = DateTime.now),
446
+ tasks: tasks.empty? ? nil : tasks.collect(&:externalize),
447
+ vault: v.empty? ? nil : v
448
+ }.compact
449
+ end
450
+
451
+
452
+ def tasks = Task.registered.filter { |task| task.refers?(self) }
453
+
454
+
455
+ def live_tasks = Task.live.filter { |task| task.refers?(self) }
456
+
457
+
458
+ def intact_tasks = live_tasks.filter { |task| task.intact? }
459
+
460
+
461
+ def self.reset = @@registry = {}
462
+
463
+
464
+ def self.register(volume) = @@registry[volume.root] = volume
465
+
466
+
467
+ def self.registered = @@registry.values
468
+
469
+
470
+ def self.intact = registered.filter { |volume| volume.intact? }
471
+
472
+
473
+ end
474
+
475
+
476
+ def self.optional(option, route)
477
+ case option
478
+ when Array then option # Array is passed verbatim
479
+ when '-' then nil # Disable adding any options with -
480
+ when /^-/ then option.split(',') # Split comma-separated string into array --foo,bar --> [--foo, bar]
481
+ else route.fetch(option) # Obtain array from the database
482
+ end
483
+ end
484
+
485
+
486
+ class Task
487
+
488
+
489
+ include Logging
490
+ extend Logging
491
+
492
+
493
+ attr_reader :tag
494
+
495
+
496
+ attr_reader :generation
497
+
498
+
499
+ attr_reader :modified
500
+
501
+
502
+ def process_options = @process_options.nil? ? [] : @process_options # As a mandatory option it should never be nil
503
+
504
+
505
+ def self.new(*args, **opts)
506
+ task = allocate
507
+ task.send(:create, *args, **opts)
508
+ register(task)
509
+ end
510
+
511
+
512
+ def self.restore(hash)
513
+ task = allocate
514
+ task.send(:restore, hash)
515
+ register(task)
516
+ end
517
+
518
+
519
+ def self.delete(*tags)
520
+ process = []
521
+ tags.each do |tag|
522
+ case (tasks = Task.lookup(tag)).size
523
+ when 0 then log.warn("no tasks matching (partial) tag #{tag}")
524
+ when 1 then process += tasks
525
+ else
526
+ tags = tasks.collect { |v| v.tag }.join(', ')
527
+ raise ArgumentError, "multiple tasks matching (partial) tag #{tag}: #{tags}"
528
+ end
529
+ end
530
+ process.each { |task| task.delete }
531
+ end
532
+
533
+
534
+ def initialize(tag: Bitferry.tag, modified: DateTime.now)
535
+ @tag = tag
536
+ @generation = 0
537
+ @modified = modified
538
+ # FIXME handle process_options at this level
539
+ end
540
+
541
+
542
+ def create(*args, **opts)
543
+ initialize(*args, **opts)
544
+ @state = :pristine
545
+ touch
546
+ end
547
+
548
+
549
+ def restore(hash)
550
+ @state = :intact
551
+ log.info("restored task #{tag}")
552
+ end
553
+
554
+
555
+ # FIXME move to Endpoint#restore
556
+ def restore_endpoint(x) = Endpoint::ROUTE.fetch(x.fetch(:endpoint).intern).restore(x)
557
+
558
+
559
+ def externalize
560
+ {
561
+ task: tag,
562
+ modified: @modified
563
+ }.compact
564
+ end
565
+
566
+
567
+ def live? = !@state.nil? && @state != :removing
568
+
569
+
570
+ def touch = @modified = DateTime.now
571
+
572
+
573
+ def delete
574
+ touch
575
+ @state = :removing
576
+ log.info("marked task #{tag} for removal")
577
+ end
578
+
579
+
580
+ def commit
581
+ case @state
582
+ when :pristine then format
583
+ when :removing then @state = nil
584
+ end
585
+ end
586
+
587
+
588
+ def self.[](tag) = @@registry[tag]
589
+
590
+
591
+ # Return list of registered tasks whose tags match at least one of specified partial tags
592
+ def self.lookup(*tags) = match(tags, registered)
593
+
594
+
595
+ # Return list of specified tasks whose tags match at least one of specified partial tags
596
+ def self.match(tags, tasks)
597
+ rxs = tags.collect { |x| Regexp.new(x) }
598
+ tasks.filter do |task|
599
+ rxs.any? { |rx| !(rx =~ task.tag).nil? }
600
+ end
601
+ end
602
+
603
+
604
+ def self.registered = @@registry.values
605
+
606
+
607
+ def self.live = registered.filter { |task| task.live? }
608
+
609
+
610
+ def self.reset = @@registry = {}
611
+
612
+
613
+ def self.register(task) = @@registry[task.tag] = task # TODO settle on task with the latest timestamp
614
+
615
+
616
+ def self.intact = live.filter { |task| task.intact? }
617
+
618
+
619
+ def self.stale = live.filter { |task| !task.intact? }
620
+
621
+
622
+ end
623
+
624
+
625
+ module Rclone
626
+
627
+
628
+ include Logging
629
+ extend Logging
630
+
631
+
632
+ def self.executable = @executable ||= (rclone = ENV['RCLONE']).nil? ? 'rclone' : rclone
633
+
634
+
635
+ def self.exec(*args)
636
+ cmd = [executable] + args
637
+ log.debug(cmd.collect(&:shellescape).join(' '))
638
+ stdout, status = Open3.capture2(*cmd)
639
+ unless status.success?
640
+ msg = "rclone exit code #{status.to_i}"
641
+ log.error(msg)
642
+ raise RuntimeError, msg
643
+ end
644
+ stdout.strip
645
+ end
646
+
647
+
648
+ def self.obscure(plain) = exec('obscure', '--', plain)
649
+
650
+
651
+ def self.reveal(token) = exec('reveal', '--', token)
652
+
653
+
654
+ end
655
+
656
+
657
+ class Rclone::Encryption
658
+
659
+
660
+ PROCESS = {
661
+ default: ['--crypt-filename-encoding', :base32, '--crypt-filename-encryption', :standard],
662
+ extended: ['--crypt-filename-encoding', :base32768, '--crypt-filename-encryption', :standard]
663
+ }
664
+ PROCESS[nil] = PROCESS[:default]
665
+
666
+
667
+ def process_options = @process_options.nil? ? [] : @process_options # As a mandatory option it should never be nil
668
+
669
+
670
+ def initialize(token, process: nil)
671
+ @process_options = Bitferry.optional(process, PROCESS)
672
+ @token = token
673
+ end
674
+
675
+
676
+ def create(password, **opts) = initialize(Rclone.obscure(password), **opts)
677
+
678
+
679
+ def restore(hash) = @process_options = hash[:rclone]
680
+
681
+
682
+ def externalize = process_options.empty? ? {} : { rclone: process_options }
683
+
684
+
685
+ def configure(task) = install_token(task)
686
+
687
+
688
+ def process(task) = ENV['RCLONE_CRYPT_PASSWORD'] = obtain_token(task)
689
+
690
+
691
+ def arguments(task) = process_options + ['--crypt-remote', encrypted(task).root.to_s]
692
+
693
+
694
+ def install_token(task)
695
+ x = decrypted(task)
696
+ raise TypeError, 'unsupported unencrypted endpoint type' unless x.is_a?(Endpoint::Bitferry)
697
+ Volume[x.volume_tag].vault[task.tag] = @token # Token is stored on the decrypted end only
698
+ end
699
+
700
+
701
+ def obtain_token(task) = Volume[decrypted(task).volume_tag].vault.fetch(task.tag)
702
+
703
+
704
+ def self.new(*args, **opts)
705
+ obj = allocate
706
+ obj.send(:create, *args, **opts)
707
+ obj
708
+ end
709
+
710
+
711
+ def self.restore(hash)
712
+ obj = ROUTE.fetch(hash.fetch(:operation).intern).allocate
713
+ obj.send(:restore, hash)
714
+ obj
715
+ end
716
+
717
+
718
+ end
719
+
720
+
721
+ class Rclone::Encrypt < Rclone::Encryption
722
+
723
+
724
+ def encrypted(task) = task.destination
725
+
726
+
727
+ def decrypted(task) = task.source
728
+
729
+
730
+ def externalize = super.merge(operation: :encrypt)
731
+
732
+
733
+ def show_operation = 'encrypt+'
734
+
735
+
736
+ def arguments(task) = super + [decrypted(task).root.to_s, ':crypt:']
737
+
738
+
739
+ end
740
+
741
+
742
+ class Rclone::Decrypt < Rclone::Encryption
743
+
744
+
745
+ def encrypted(task) = task.source
746
+
747
+
748
+ def decrypted(task) = task.destination
749
+
750
+
751
+ def externalize = super.merge(operation: :decrypt)
752
+
753
+
754
+ def show_operation = 'decrypt+'
755
+
756
+
757
+ def arguments(task) = super + [':crypt:', decrypted(task).root.to_s]
758
+
759
+ end
760
+
761
+
762
+ Rclone::Encryption::ROUTE = {
763
+ encrypt: Rclone::Encrypt,
764
+ decrypt: Rclone::Decrypt
765
+ }
766
+
767
+
768
+ class Rclone::Task < Task
769
+
770
+
771
+ attr_reader :source, :destination
772
+
773
+
774
+ attr_reader :encryption
775
+
776
+
777
+ attr_reader :token
778
+
779
+
780
+ PROCESS = {
781
+ default: ['--metadata']
782
+ }
783
+ PROCESS[nil] = PROCESS[:default]
784
+
785
+
786
+ def initialize(source, destination, encryption: nil, process: nil, **opts)
787
+ super(**opts)
788
+ @process_options = Bitferry.optional(process, PROCESS)
789
+ @source = source.is_a?(Endpoint) ? source : Bitferry.endpoint(source)
790
+ @destination = destination.is_a?(Endpoint) ? destination : Bitferry.endpoint(destination)
791
+ @encryption = encryption
792
+ end
793
+
794
+
795
+ def create(*args, process: nil, **opts)
796
+ super(*args, process: process, **opts)
797
+ encryption.configure(self) unless encryption.nil?
798
+ end
799
+
800
+
801
+ def show_status = "#{show_operation} #{source.show_status} #{show_direction} #{destination.show_status}"
802
+
803
+
804
+ def show_operation = encryption.nil? ? '' : encryption.show_operation
805
+
806
+
807
+ def show_direction = '-->'
808
+
809
+
810
+ def intact? = live? && source.intact? && destination.intact?
811
+
812
+
813
+ def refers?(volume) = source.refers?(volume) || destination.refers?(volume)
814
+
815
+
816
+ def touch
817
+ @generation = [source.generation, destination.generation].max + 1
818
+ super
819
+ end
820
+
821
+
822
+ def format = nil
823
+
824
+
825
+ def common_options
826
+ [
827
+ '--config', Bitferry.windows? ? 'NUL' : '/dev/null',
828
+ case Bitferry.verbosity
829
+ when :verbose then '--verbose'
830
+ when :quiet then '--quiet'
831
+ else nil
832
+ end,
833
+ Bitferry.verbosity == :verbose ? '--progress' : nil,
834
+ Bitferry.simulate? ? '--dry-run' : nil,
835
+ ].compact
836
+ end
837
+
838
+
839
+ def process_arguments
840
+ ['--filter', "- #{Volume::STORAGE}", '--filter', "- #{Volume::STORAGE_}"] + common_options + process_options + (
841
+ encryption.nil? ? [source.root.to_s, destination.root.to_s] : encryption.arguments(self)
842
+ )
843
+ end
844
+
845
+
846
+ def execute(*args)
847
+ cmd = [Rclone.executable] + args
848
+ cms = cmd.collect(&:shellescape).join(' ')
849
+ puts cms if Bitferry.verbosity == :verbose
850
+ log.info(cms)
851
+ status = Open3.pipeline(cmd).first
852
+ raise "rclone exit code #{status.exitstatus}" unless status.success?
853
+ status.success?
854
+ end
855
+
856
+
857
+ def process
858
+ log.info("processing task #{tag}")
859
+ encryption.process(self) unless encryption.nil?
860
+ execute(*process_arguments)
861
+ end
862
+
863
+
864
+ def externalize
865
+ super.merge(
866
+ source: source.externalize,
867
+ destination: destination.externalize,
868
+ encryption: encryption.nil? ? nil : encryption.externalize,
869
+ rclone: process_options.empty? ? nil : process_options
870
+ ).compact
871
+ end
872
+
873
+
874
+ def restore(hash)
875
+ initialize(
876
+ restore_endpoint(hash.fetch(:source)),
877
+ restore_endpoint(hash.fetch(:destination)),
878
+ tag: hash.fetch(:task),
879
+ modified: hash.fetch(:modified, DateTime.now),
880
+ process: hash[:rclone],
881
+ encryption: hash[:encryption].nil? ? nil : Rclone::Encryption.restore(hash[:encryption])
882
+ )
883
+ super(hash)
884
+ end
885
+
886
+
887
+ end
888
+
889
+
890
+ class Rclone::Copy < Rclone::Task
891
+
892
+
893
+ def process_arguments = ['copy'] + super
894
+
895
+
896
+ def externalize = super.merge(operation: :copy)
897
+
898
+
899
+ def show_operation = super + 'copy'
900
+
901
+
902
+ end
903
+
904
+
905
+ class Rclone::Update < Rclone::Task
906
+
907
+
908
+ def process_arguments = ['copy', '--update'] + super
909
+
910
+
911
+ def externalize = super.merge(operation: :update)
912
+
913
+
914
+ def show_operation = super + 'update'
915
+
916
+
917
+ end
918
+
919
+
920
+ class Rclone::Synchronize < Rclone::Task
921
+
922
+
923
+ def process_arguments = ['sync'] + super
924
+
925
+
926
+ def externalize = super.merge(operation: :synchronize)
927
+
928
+
929
+ def show_operation = super + 'synchronize'
930
+
931
+
932
+ end
933
+
934
+
935
+ class Rclone::Equalize < Rclone::Task
936
+
937
+
938
+ def process_arguments = ['bisync', '--resync'] + super
939
+
940
+
941
+ def externalize = super.merge(operation: :equalize)
942
+
943
+
944
+ def show_operation = super + 'equalize'
945
+
946
+
947
+ def show_direction = '<->'
948
+
949
+
950
+ end
951
+
952
+
953
+ module Restic
954
+
955
+
956
+ include Logging
957
+ extend Logging
958
+
959
+
960
+ def self.executable = @executable ||= (restic = ENV['RESTIC']).nil? ? 'restic' : restic
961
+
962
+
963
+ def self.exec(*args)
964
+ cmd = [executable] + args
965
+ log.debug(cmd.collect(&:shellescape).join(' '))
966
+ stdout, status = Open3.capture2(*cmd)
967
+ unless status.success?
968
+ msg = "restic exit code #{status.to_i}"
969
+ log.error(msg)
970
+ raise RuntimeError, msg
971
+ end
972
+ stdout.strip
973
+ end
974
+
975
+
976
+ end
977
+
978
+
979
+ class Restic::Task < Task
980
+
981
+
982
+ attr_reader :directory, :repository
983
+
984
+
985
+ def initialize(directory, repository, **opts)
986
+ super(**opts)
987
+ @directory = directory.is_a?(Endpoint) ? directory : Bitferry.endpoint(directory)
988
+ @repository = repository.is_a?(Endpoint) ? repository : Bitferry.endpoint(repository)
989
+ end
990
+
991
+
992
+ def create(directory, repository, password, **opts)
993
+ super(directory, repository, **opts)
994
+ raise TypeError, 'unsupported unencrypted endpoint type' unless self.directory.is_a?(Endpoint::Bitferry)
995
+ Volume[self.directory.volume_tag].vault[tag] = Rclone.obscure(@password = password) # Token is stored on the decrypted end only
996
+ end
997
+
998
+
999
+ def password = @password ||= Rclone.reveal(Volume[directory.volume_tag].vault.fetch(tag))
1000
+
1001
+
1002
+ def intact? = live? && directory.intact? && repository.intact?
1003
+
1004
+
1005
+ def refers?(volume) = directory.refers?(volume) || repository.refers?(volume)
1006
+
1007
+
1008
+ def touch
1009
+ @generation = [directory.generation, repository.generation].max + 1
1010
+ super
1011
+ end
1012
+
1013
+ def format = nil
1014
+
1015
+
1016
+ def common_options
1017
+ [
1018
+ case Bitferry.verbosity
1019
+ when :verbose then '--verbose'
1020
+ when :quiet then '--quiet'
1021
+ else nil
1022
+ end,
1023
+ '-r', repository.root.to_s
1024
+ ].compact
1025
+ end
1026
+
1027
+
1028
+ def execute(*args, simulate: false, chdir: nil)
1029
+ cmd = [Restic.executable] + args
1030
+ ENV['RESTIC_PASSWORD'] = password
1031
+ cms = cmd.collect(&:shellescape).join(' ')
1032
+ puts cms if Bitferry.verbosity == :verbose
1033
+ log.info(cms)
1034
+ if simulate
1035
+ log.info('(simulated)')
1036
+ true
1037
+ else
1038
+ wd = Dir.getwd unless chdir.nil?
1039
+ begin
1040
+ Dir.chdir(chdir) unless chdir.nil?
1041
+ status = Open3.pipeline(cmd).first
1042
+ raise "restic exit code #{status.exitstatus}" unless status.success?
1043
+ ensure
1044
+ Dir.chdir(wd) unless chdir.nil?
1045
+ end
1046
+ end
1047
+ end
1048
+
1049
+
1050
+ def externalize
1051
+ super.merge(
1052
+ directory: directory.externalize,
1053
+ repository: repository.externalize,
1054
+ ).compact
1055
+ end
1056
+
1057
+
1058
+ def restore(hash)
1059
+ initialize(
1060
+ restore_endpoint(hash.fetch(:directory)),
1061
+ restore_endpoint(hash.fetch(:repository)),
1062
+ tag: hash.fetch(:task),
1063
+ modified: hash.fetch(:modified, DateTime.now)
1064
+ )
1065
+ super(hash)
1066
+ end
1067
+
1068
+
1069
+ end
1070
+
1071
+
1072
+ class Restic::Backup < Restic::Task
1073
+
1074
+
1075
+ PROCESS = {
1076
+ default: ['--no-cache']
1077
+ }
1078
+ PROCESS[nil] = PROCESS[:default]
1079
+
1080
+
1081
+ FORGET = {
1082
+ default: ['--prune', '--keep-within-hourly', '24h', '--keep-within-daily', '7d', '--keep-within-weekly', '30d', '--keep-within-monthly', '1y', '--keep-within-yearly', '100y']
1083
+ }
1084
+ FORGET[nil] = nil # Skip processing retention policy by default
1085
+
1086
+
1087
+ CHECK = {
1088
+ default: [],
1089
+ full: ['--read-data']
1090
+ }
1091
+ CHECK[nil] = nil # Skip integrity checking by default
1092
+
1093
+
1094
+ attr_reader :forget_options
1095
+ attr_reader :check_options
1096
+
1097
+
1098
+ def create(*args, format: nil, process: nil, forget: nil, check: nil, **opts)
1099
+ super(*args, **opts)
1100
+ @format = format
1101
+ @process_options = Bitferry.optional(process, PROCESS)
1102
+ @forget_options = Bitferry.optional(forget, FORGET)
1103
+ @check_options = Bitferry.optional(check, CHECK)
1104
+ end
1105
+
1106
+
1107
+ def show_status = "#{show_operation} #{directory.show_status} #{show_direction} #{repository.show_status}"
1108
+
1109
+
1110
+ def show_operation = 'encrypt+backup'
1111
+
1112
+
1113
+ def show_direction = '-->'
1114
+
1115
+
1116
+ def process
1117
+ begin
1118
+ log.info("processing task #{tag}")
1119
+ execute('backup', '.', '--tag', "bitferry,#{tag}", '--exclude', Volume::STORAGE, '--exclude', Volume::STORAGE_, *process_options, *common_options_simulate, chdir: directory.root)
1120
+ unless check_options.nil?
1121
+ log.info("checking repository in #{repository.root}")
1122
+ execute('check', *check_options, *common_options)
1123
+ end
1124
+ unless forget_options.nil?
1125
+ log.info("performing repository maintenance tasks in #{repository.root}")
1126
+ execute('forget', '--tag', "bitferry,#{tag}", *forget_options.collect(&:to_s), *common_options_simulate)
1127
+ end
1128
+ true
1129
+ rescue
1130
+ false
1131
+ end
1132
+ end
1133
+
1134
+
1135
+ def common_options_simulate = common_options + [Bitferry.simulate? ? '--dry-run' : nil].compact
1136
+
1137
+
1138
+ def externalize
1139
+ restic = {
1140
+ process: process_options,
1141
+ forget: forget_options,
1142
+ check: check_options
1143
+ }.compact
1144
+ super.merge({
1145
+ operation: :backup,
1146
+ restic: restic.empty? ? nil : restic
1147
+ }.compact)
1148
+ end
1149
+
1150
+
1151
+ def restore(hash)
1152
+ super
1153
+ opts = hash.fetch(:restic, {})
1154
+ @process_options = opts[:process]
1155
+ @forget_options = opts[:forget]
1156
+ @check_options = opts[:check]
1157
+ end
1158
+
1159
+
1160
+ def format
1161
+ if Bitferry.simulate?
1162
+ log.info('skipped repository initialization (simulation)')
1163
+ else
1164
+ log.info("initializing repository for task #{tag}")
1165
+ if @format == true
1166
+ log.debug("wiping repository in #{repository.root}")
1167
+ ['config', 'data', 'index', 'keys', 'locks', 'snapshots'].each { |x| FileUtils.rm_rf(File.join(repository.root.to_s, x)) }
1168
+ end
1169
+ if @format == false
1170
+ # TODO validate existing repo
1171
+ log.info("attached to existing repository for task #{tag} in #{repository.root}")
1172
+ else
1173
+ begin
1174
+ execute(*common_options, 'init')
1175
+ log.info("initialized repository for task #{tag} in #{repository.root}")
1176
+ rescue
1177
+ log.fatal("failed to initialize repository for task #{tag} in #{repository.root}")
1178
+ raise
1179
+ end
1180
+ end
1181
+ end
1182
+ @state = :intact
1183
+ end
1184
+
1185
+ end
1186
+
1187
+
1188
+ class Restic::Restore < Restic::Task
1189
+
1190
+
1191
+ PROCESS = {
1192
+ default: ['--no-cache', '--sparse']
1193
+ }
1194
+ PROCESS[nil] = PROCESS[:default]
1195
+
1196
+
1197
+ def create(*args, process: nil, **opts)
1198
+ super(*args, **opts)
1199
+ @process_options = Bitferry.optional(process, PROCESS)
1200
+ end
1201
+
1202
+
1203
+ def show_status = "#{show_operation} #{repository.show_status} #{show_direction} #{directory.show_status}"
1204
+
1205
+
1206
+ def show_operation = 'decrypt+restore'
1207
+
1208
+
1209
+ def show_direction = '-->'
1210
+
1211
+
1212
+ def externalize
1213
+ restic = {
1214
+ process: process_options
1215
+ }.compact
1216
+ super.merge({
1217
+ operation: :restore,
1218
+ restic: restic.empty? ? nil : restic
1219
+ }.compact)
1220
+ end
1221
+
1222
+
1223
+ def restore(hash)
1224
+ super
1225
+ opts = hash.fetch(:rclone, {})
1226
+ @process_options = opts[:process]
1227
+ end
1228
+
1229
+
1230
+ def process
1231
+ log.info("processing task #{tag}")
1232
+ begin
1233
+ # FIXME restore specifically tagged latest snapshot
1234
+ execute('restore', 'latest', '--target', '.', *process_options, *common_options, simulate: Bitferry.simulate?, chdir: directory.root)
1235
+ true
1236
+ rescue
1237
+ false
1238
+ end
1239
+ end
1240
+
1241
+
1242
+ end
1243
+
1244
+
1245
+ Task::ROUTE = {
1246
+ copy: Rclone::Copy,
1247
+ update: Rclone::Update,
1248
+ synchronize: Rclone::Synchronize,
1249
+ equalize: Rclone::Equalize,
1250
+ backup: Restic::Backup,
1251
+ restore: Restic::Restore
1252
+ }
1253
+
1254
+
1255
+ class Endpoint
1256
+
1257
+
1258
+ def self.restore(hash)
1259
+ endpoint = allocate
1260
+ endpoint.send(:restore, hash)
1261
+ endpoint
1262
+ end
1263
+
1264
+
1265
+ end
1266
+
1267
+
1268
+ class Endpoint::Local < Endpoint
1269
+
1270
+
1271
+ attr_reader :root
1272
+
1273
+
1274
+ def initialize(root) = @root = Pathname.new(root).realdirpath
1275
+
1276
+
1277
+ def restore(hash) = initialize(hash.fetch(:root))
1278
+
1279
+
1280
+ def externalize
1281
+ {
1282
+ endpoint: :local,
1283
+ root: root
1284
+ }
1285
+ end
1286
+
1287
+
1288
+ def show_status = root.to_s
1289
+
1290
+
1291
+ def intact? = true
1292
+
1293
+
1294
+ def refers?(volume) = false
1295
+
1296
+
1297
+ def generation = 0
1298
+
1299
+
1300
+ end
1301
+
1302
+
1303
+ class Endpoint::Rclone < Endpoint
1304
+ # TODO
1305
+ end
1306
+
1307
+
1308
+ class Endpoint::Bitferry < Endpoint
1309
+
1310
+
1311
+ attr_reader :volume_tag
1312
+
1313
+
1314
+ attr_reader :path
1315
+
1316
+
1317
+ def root = Volume[volume_tag].root.join(path)
1318
+
1319
+
1320
+ def initialize(volume, path)
1321
+ @volume_tag = volume.tag
1322
+ @path = Pathname.new(path)
1323
+ raise ArgumentError, "expected relative path but got #{self.path}" unless (/^[\.\/]/ =~ self.path.to_s).nil?
1324
+ end
1325
+
1326
+
1327
+ def restore(hash)
1328
+ @volume_tag = hash.fetch(:volume)
1329
+ @path = Pathname.new(hash.fetch(:path))
1330
+ end
1331
+
1332
+
1333
+ def externalize
1334
+ {
1335
+ endpoint: :bitferry,
1336
+ volume: volume_tag,
1337
+ path: path
1338
+ }
1339
+ end
1340
+
1341
+
1342
+ def show_status = intact? ? ":#{volume_tag}:#{path}" : ":{#{volume_tag}}:#{path}"
1343
+
1344
+
1345
+ def intact? = !Volume[volume_tag].nil?
1346
+
1347
+
1348
+ def refers?(volume) = volume.tag == volume_tag
1349
+
1350
+
1351
+ def generation
1352
+ v = Volume[volume_tag]
1353
+ v ? v.generation : 0
1354
+ end
1355
+
1356
+
1357
+ end
1358
+
1359
+
1360
+ Endpoint::ROUTE = {
1361
+ local: Endpoint::Local,
1362
+ rclone: Endpoint::Rclone,
1363
+ bitferry: Endpoint::Bitferry
1364
+ }
1365
+
1366
+
1367
+ reset
1368
+
1369
+
1370
+ end