synco 1.0.0

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