synco 1.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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +22 -0
  3. data/.rspec +4 -0
  4. data/.simplecov +9 -0
  5. data/.travis.yml +14 -0
  6. data/Gemfile +11 -0
  7. data/README.md +246 -0
  8. data/Rakefile +8 -0
  9. data/bin/synco +30 -0
  10. data/lib/synco.rb +51 -0
  11. data/lib/synco/command.rb +71 -0
  12. data/lib/synco/command/disk.rb +55 -0
  13. data/lib/synco/command/prune.rb +166 -0
  14. data/lib/synco/command/rotate.rb +86 -0
  15. data/lib/synco/command/spawn.rb +39 -0
  16. data/lib/synco/compact_formatter.rb +115 -0
  17. data/lib/synco/controller.rb +119 -0
  18. data/lib/synco/directory.rb +60 -0
  19. data/lib/synco/disk.rb +68 -0
  20. data/lib/synco/method.rb +56 -0
  21. data/lib/synco/methods/rsync.rb +162 -0
  22. data/lib/synco/methods/scp.rb +44 -0
  23. data/lib/synco/methods/zfs.rb +60 -0
  24. data/lib/synco/scope.rb +247 -0
  25. data/lib/synco/script.rb +128 -0
  26. data/lib/synco/server.rb +90 -0
  27. data/lib/synco/shell.rb +44 -0
  28. data/lib/synco/shells/ssh.rb +52 -0
  29. data/lib/synco/version.rb +23 -0
  30. data/media/LSync Logo.artx/Preview/preview.png +0 -0
  31. data/media/LSync Logo.artx/QuickLook/Preview.pdf +0 -0
  32. data/media/LSync Logo.artx/doc.thread +0 -0
  33. data/media/LSync Logo.png +0 -0
  34. data/spec/synco/backup_script.rb +63 -0
  35. data/spec/synco/directory_spec.rb +33 -0
  36. data/spec/synco/local_backup.rb +56 -0
  37. data/spec/synco/local_sync.rb +91 -0
  38. data/spec/synco/method_spec.rb +62 -0
  39. data/spec/synco/rsync_spec.rb +89 -0
  40. data/spec/synco/scp_spec.rb +58 -0
  41. data/spec/synco/script_spec.rb +51 -0
  42. data/spec/synco/shell_spec.rb +42 -0
  43. data/spec/synco/usb_spec.rb +76 -0
  44. data/spec/synco/zfs_spec.rb +50 -0
  45. data/synco.gemspec +35 -0
  46. metadata +254 -0
@@ -0,0 +1,119 @@
1
+ # Copyright, 2016, by Samuel G. D. Williams. <http://www.codeotaku.com>
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ # THE SOFTWARE.
20
+
21
+ module Synco
22
+ # Basic event handling and delegation.
23
+ class Controller
24
+ def self.build(*arguments, **options, &block)
25
+ controller = self.new(*arguments, **options)
26
+
27
+ if block_given?
28
+ yield(controller)
29
+ end
30
+
31
+ controller.freeze
32
+
33
+ return controller
34
+ end
35
+
36
+ def initialize
37
+ @events = Hash.new{|hash,key| hash[key] = Array.new}
38
+ @aborted = false
39
+ end
40
+
41
+ def freeze
42
+ @events.freeze
43
+
44
+ super
45
+ end
46
+
47
+ attr :events
48
+
49
+ # Register an event handler which may be triggered when an event is fired.
50
+ def on(event, &block)
51
+ @events[event] << block
52
+ end
53
+
54
+ # Fire an event which calls all registered event handlers in the order they were defined.
55
+ # The first argument is used to #instance_eval any handlers.
56
+ def fire(event, *args)
57
+ return false unless @events.key?(event)
58
+
59
+ handled = false
60
+
61
+ scope = args.shift
62
+
63
+ @events[event].each do |handler|
64
+ handled = true
65
+
66
+ if scope
67
+ scope.instance_exec(*args, &handler)
68
+ else
69
+ handler.call
70
+ end
71
+ end
72
+
73
+ return handled
74
+ end
75
+
76
+ # Try executing a given block of code and fire appropriate events.
77
+ #
78
+ # The sequence of events (registered via #on) are as follows:
79
+ # [+:prepare+] Fired before the block is executed. May call #abort! to cancel execution.
80
+ # [+:success+] Fired after the block of code has executed without raising an exception.
81
+ # [+:failure+] Fired if an exception is thrown during normal execution.
82
+ # [+:finish+] Fired at the end of execution regardless of failure.
83
+ #
84
+ # If #abort! has been called in the past, this function returns immediately.
85
+ def try(*arguments)
86
+ return if @aborted
87
+
88
+ begin
89
+ catch(abort_name) do
90
+ fire(:prepare, *arguments)
91
+
92
+ yield
93
+
94
+ fire(:success, *arguments)
95
+ end
96
+ rescue Exception => exception
97
+ # Propagage the exception unless it was handled in some specific way.
98
+ raise unless fire(:failure, *arguments, exception)
99
+ ensure
100
+ fire(:finish, *arguments)
101
+ end
102
+ end
103
+
104
+ # Abort the current event handler. Aborting an event handler persistently implies that in
105
+ # the future it will still be aborted; thus calling #try will have no effect.
106
+ def abort!(persistent = false)
107
+ @aborted = true if persistent
108
+
109
+ throw abort_name
110
+ end
111
+
112
+ private
113
+
114
+ # The name used for throwing abortions.
115
+ def abort_name
116
+ ("abort_" + self.class.name).downcase.to_sym
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,60 @@
1
+ # Copyright, 2016, by Samuel G. D. Williams. <http://www.codeotaku.com>
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ # THE SOFTWARE.
20
+
21
+ require_relative 'controller'
22
+
23
+ module Synco
24
+ class AbsolutePathError < ArgumentError
25
+ end
26
+
27
+ # A specific directory which is relative to the root of a given server. Specific configuration details
28
+ # such as excludes and other options may be specified.
29
+ class Directory < Controller
30
+ def initialize(path, arguments: [])
31
+ if path.start_with?('/')
32
+ raise AbsolutePathError.new("#{path} must be relative!")
33
+ end
34
+
35
+ super()
36
+
37
+ @arguments = arguments
38
+ @path = self.class.normalize(path)
39
+ end
40
+
41
+ attr :path
42
+ attr :arguments
43
+
44
+ def depth
45
+ self.class.depth(@path)
46
+ end
47
+
48
+ def to_s
49
+ @path
50
+ end
51
+
52
+ def self.normalize(path)
53
+ path.end_with?('/') ? path : path + '/'
54
+ end
55
+
56
+ def self.depth(path)
57
+ path.count('/')
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,68 @@
1
+ # Copyright, 2016, by Samuel G. D. Williams. <http://www.codeotaku.com>
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ # THE SOFTWARE.
20
+
21
+ module Synco
22
+ # Depending on how you have things set up, you'll probably want to add
23
+ # %wheel ALL=(root) NOPASSWD: /bin/mount
24
+ # %wheel ALL=(root) NOPASSWD: /bin/umount
25
+ # to /etc/sudoers.d/synco
26
+ module LinuxDisk
27
+ def self.available?(disk_name)
28
+ File.exist?("/dev/disk/by-label/#{disk_name}")
29
+ end
30
+
31
+ def self.mount(path, disk_name = nil)
32
+ if disk_name
33
+ system("sudo", "mount", "-L", disk_name, path)
34
+ else
35
+ system("sudo", "mount", path)
36
+ end
37
+ end
38
+
39
+ def self.unmount(path)
40
+ system("sudo", "umount", path)
41
+ end
42
+ end
43
+
44
+ module DarwinDisk
45
+ DISKUTIL = "diskutil"
46
+
47
+ def self.available?(disk_name)
48
+ system(DISKUTIL, "list", disk_name)
49
+ end
50
+
51
+ def self.mount(path, disk_name = nil)
52
+ disk_name ||= File.basename(path)
53
+
54
+ system(DISKUTIL, "mount", "-mountPoint", path, disk_name)
55
+ end
56
+
57
+ def self.unmount(path)
58
+ system(DISKUTIL, "unmount", path)
59
+ end
60
+ end
61
+
62
+ case RUBY_PLATFORM
63
+ when /darwin/
64
+ Disk = DarwinDisk
65
+ when /linux/
66
+ Disk = LinuxDisk
67
+ end
68
+ end
@@ -0,0 +1,56 @@
1
+ # Copyright, 2016, by Samuel G. D. Williams. <http://www.codeotaku.com>
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ # THE SOFTWARE.
20
+
21
+ require 'fileutils'
22
+ require 'pathname'
23
+ require_relative 'controller'
24
+
25
+ module Synco
26
+ SNAPSHOT_NAME = 'latest.snapshot'
27
+ LATEST_NAME = 'latest'
28
+ BACKUP_NAME = '%Y.%m.%d-%H.%M.%S'
29
+ BACKUP_TIMEZONE = 'UTC'
30
+
31
+ # A backup method provides the interface to copy data from one system to another.
32
+ class Method < Controller
33
+ def initialize(*command, arguments: [], **options)
34
+ super()
35
+
36
+ @command = command.empty? ? default_command : command
37
+ @arguments = arguments
38
+ @options = options
39
+ end
40
+
41
+ attr :options
42
+ attr :arguments
43
+
44
+ def call(scope, arguments: [])
45
+ server = scope.current_server
46
+ directory = scope.directory
47
+
48
+ server.run(
49
+ *@command,
50
+ *arguments,
51
+ scope.master_server.connection_string(directory, on: server),
52
+ scope.target_server.connection_string(directory, on: server)
53
+ )
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,162 @@
1
+ # Copyright, 2016, by Samuel G. D. Williams. <http://www.codeotaku.com>
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ # THE SOFTWARE.
20
+
21
+ require_relative '../method'
22
+ require 'shellwords'
23
+
24
+ module Synco
25
+ module Methods
26
+ # RSync Exit Codes as of 2011:
27
+ # 0 Success
28
+ # 1 Syntax or usage error
29
+ # 2 Protocol incompatibility
30
+ # 3 Errors selecting input/output files, dirs
31
+ # 4 Requested action not supported: an attempt was made to manipulate 64-bit files on a platform
32
+ # that cannot support them; or an option was specified that is supported by the client and not by the server.
33
+ # 5 Error starting client-server protocol
34
+ # 6 Daemon unable to append to log-file
35
+ # 10 Error in socket I/O
36
+ # 11 Error in file I/O
37
+ # 12 Error in rsync protocol data stream
38
+ # 13 Errors with program diagnostics
39
+ # 14 Error in IPC code
40
+ # 20 Received SIGUSR1 or SIGINT
41
+ # 21 Some error returned by waitpid()
42
+ # 22 Error allocating core memory buffers
43
+ # 23 Partial transfer due to error
44
+ # 24 Partial transfer due to vanished source files
45
+ # 25 The --max-delete limit stopped deletions
46
+ # 30 Timeout in data send/receive
47
+ # 35 Timeout waiting for daemon connection
48
+
49
+ class RSync < Method
50
+ def default_command
51
+ ['rsync']
52
+ end
53
+
54
+ def initialize(*command, arguments: [], archive: false, stats: nil, **options)
55
+ if archive
56
+ arguments << '--archive'
57
+ end
58
+
59
+ if stats
60
+ arguments << '--stats'
61
+ end
62
+
63
+ super
64
+ end
65
+
66
+ # This escapes the -e argument to rsync, as it's argv parser is a bit.. unique.
67
+ def escape(command)
68
+ case command
69
+ when Array
70
+ command.collect{|arg| escape(arg)}.join(' ')
71
+ when String
72
+ command =~ /\s|"|'/ ? command.dump : command
73
+ else
74
+ escape(command.to_s)
75
+ end
76
+ end
77
+
78
+ def connect_arguments(master_server, target_server)
79
+ return [] if master_server.same_host?(target_server)
80
+
81
+ # This gives the command required to connect to the remote server, e.g. `ssh example.com`
82
+ command = target_server.connection_command
83
+
84
+ # RSync -e option simply appends the hostname. There is no way to control this behaviour.
85
+ if command.last != target_server.host
86
+ raise ArgumentError.new("RSync shell requires hostname at end of command! #{command.inspect}")
87
+ else
88
+ command.pop
89
+ end
90
+
91
+ return ['-e', escape(command)]
92
+ end
93
+
94
+ def call(scope)
95
+ master_server = scope.master_server
96
+ target_server = scope.target_server
97
+ directory = scope.directory
98
+
99
+ master_server.run(
100
+ *@command,
101
+ *@arguments,
102
+ *directory.arguments,
103
+ *connect_arguments(master_server, target_server),
104
+ master_server.connection_string(directory, on: master_server),
105
+ target_server.connection_string(directory, on: master_server)
106
+ )
107
+ rescue CommandFailure => failure
108
+ raise unless failure.status.to_i == 24
109
+ end
110
+ end
111
+
112
+ class RSyncSnapshot < RSync
113
+ def initialize(*command, arguments: [], archive: true, stats: true, **options)
114
+ super
115
+ end
116
+
117
+ def snapshot_name
118
+ @options[:snapshot_name] || SNAPSHOT_NAME
119
+ end
120
+
121
+ def latest_name
122
+ @options[:latest_name] || LATEST_NAME
123
+ end
124
+
125
+ def compute_incremental_path(directory)
126
+ File.join(snapshot_name, directory.path)
127
+ end
128
+
129
+ def compute_link_arguments(directory, incremental_path)
130
+ depth = Directory.depth(incremental_path)
131
+
132
+ latest_path = File.join("../" * depth, latest_name, directory.path)
133
+
134
+ return ['--link-dest', latest_path]
135
+ end
136
+
137
+ def call(scope)
138
+ master_server = scope.master_server
139
+ target_server = scope.target_server
140
+
141
+ directory = scope.directory
142
+ incremental_path = compute_incremental_path(directory)
143
+ link_arguments = compute_link_arguments(directory, incremental_path)
144
+
145
+ # Create the destination backup directory
146
+ target_server.run('mkdir', '-p', target_server.full_path(incremental_path))
147
+
148
+ master_server.run(
149
+ *@command,
150
+ *@arguments,
151
+ *directory.arguments,
152
+ *connect_arguments(master_server, target_server),
153
+ *link_arguments,
154
+ master_server.connection_string(directory, on: master_server),
155
+ target_server.connection_string(incremental_path, on: master_server)
156
+ )
157
+ rescue CommandFailure => failure
158
+ raise unless failure.status.to_i == 24
159
+ end
160
+ end
161
+ end
162
+ end