bitferry 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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