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,44 @@
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
+
23
+ module Synco
24
+ module Methods
25
+ class SCP < Method
26
+ def default_command
27
+ ['scp', '-pr']
28
+ end
29
+
30
+ def call(scope)
31
+ server = scope.current_server
32
+ directory = scope.directory
33
+
34
+ server.run(
35
+ *@command,
36
+ *arguments,
37
+ # If the destination directory already exists, scp will create the source directory inside the destinatio directory. This behaviour means that running scp multiple times gives different results, i.e. the first time it will copy source/* to destination/*, but the second time you will end up with destination/source/*. Putting a dot after the first path alleviates this issue for some reason.
38
+ scope.master_server.connection_string(directory, on: server) + '.',
39
+ scope.target_server.connection_string(directory, on: server)
40
+ )
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,60 @@
1
+ # Copyright, 2016, 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
+
23
+ module Synco
24
+ module Methods
25
+ class ZFS < Method
26
+ def default_command
27
+ ['zfs', '-rnv']
28
+ end
29
+
30
+ def call(scope, arguments: [])
31
+ from_server = scope.current_server
32
+ master_server = scope.master_server
33
+ target_server = scope.target_server
34
+ directory = scope.directory
35
+
36
+ send_command = [
37
+ *@command,
38
+ "send",
39
+ master_server.full_path(directory)
40
+ ]
41
+
42
+ receive_command = [
43
+ *@command,
44
+ "receive",
45
+ target_server.full_path(directory)
46
+ ]
47
+
48
+ input, output = IO.pipe
49
+
50
+ Fiber.new do
51
+ master_server.run(*send_command, out: output, from: from_server)
52
+ output.close
53
+ end.resume
54
+
55
+ target_server.run(*receive_command, in: input, from: from_server)
56
+ input.close
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,247 @@
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 'script'
22
+
23
+ require 'process/group'
24
+
25
+ require 'logger'
26
+ require 'delegate'
27
+
28
+ module Synco
29
+ class CommandFailure < RuntimeError
30
+ def initialize(command, status)
31
+ @command = command
32
+ @status = status
33
+
34
+ super "Command #{command.inspect} failed: #{status}!"
35
+ end
36
+
37
+ attr :command
38
+ attr :status
39
+ end
40
+
41
+ class Runner
42
+ def initialize(*scripts, logger: nil, verbose: false)
43
+ @scripts = scripts
44
+
45
+ @logger = logger || Logger.new($stderr).tap do |logger|
46
+ logger.formatter = CompactFormatter.new
47
+
48
+ if verbose or ENV['SYNCO_VERBOSE']
49
+ logger.level = Logger::DEBUG
50
+ else
51
+ logger.level = Logger::INFO
52
+ end
53
+ end
54
+ end
55
+
56
+ attr :scripts
57
+ attr :logger
58
+
59
+ def call
60
+ start_time = Time.now
61
+
62
+ logger.info "===== Starting at #{start_time} ====="
63
+
64
+ Process::Group.wait do |group|
65
+ @scripts.each do |script|
66
+ Fiber.new do
67
+ ScriptScope.new(script, @logger, group).call
68
+ end.resume
69
+ end
70
+ end
71
+ ensure
72
+ end_time = Time.now
73
+ logger.info "[Time]: (#{end_time - start_time}s)."
74
+ logger.info "===== Finished backup at #{end_time} ====="
75
+ end
76
+ end
77
+
78
+ class ScriptScope
79
+ def initialize(script, logger, group)
80
+ @script = script
81
+ @logger = logger
82
+ @group = group
83
+
84
+ @current_server = ServerScope.new(@script.current_server, self)
85
+ @master_server = ServerScope.new(@script.master_server, self, @current_server)
86
+ end
87
+
88
+ attr :script
89
+ attr :logger
90
+ attr :group
91
+ attr :master_server
92
+ attr :current_server
93
+
94
+ def method
95
+ @script.method
96
+ end
97
+
98
+ def call
99
+ if @script.running_on_master?
100
+ logger.info "We are the master server..."
101
+ else
102
+ logger.info "We are not the master server..."
103
+ logger.info "Master server is #{@master}..."
104
+ end
105
+
106
+ @script.try(self) do
107
+ # This allows events to run on the master server if specified, before running any backups.
108
+
109
+ @master_server.try(master_target_server) do
110
+ method.try(self) do
111
+ logger.info "Running backups for server #{@current_server}..."
112
+
113
+ run_servers(group)
114
+ end
115
+ end
116
+ end
117
+ end
118
+
119
+ private
120
+
121
+ def master_target_server
122
+ TargetScope.new(self, @master_server)
123
+ end
124
+
125
+ def target_servers
126
+ @script.servers.each do |name, server|
127
+ # server is always a data destination, therefore server can't be @master_server:
128
+ next if @master_server.eql?(server)
129
+
130
+ yield ServerScope.new(server, self, @current_server)
131
+ end
132
+ end
133
+
134
+ # This function runs the method for each directory and server combination specified.
135
+ def run_servers(group)
136
+ target_servers do |server|
137
+ sync_scope = TargetScope.new(self, server)
138
+
139
+ logger.info "===== Processing ====="
140
+ logger.info "[Master]: #{master_server}"
141
+ logger.info "[Target]: #{server}"
142
+
143
+ server.try(sync_scope) do
144
+ @script.directories.each do |directory|
145
+ directory_scope = DirectoryScope.new(sync_scope, directory)
146
+
147
+ logger.info "[Directory]: #{directory}"
148
+ directory.try(directory_scope) do
149
+ method.call(directory_scope)
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+
157
+ class LogPipe < DelegateClass(IO)
158
+ def initialize(logger, level = :info)
159
+ @input, @output = IO.pipe
160
+ @logger = logger
161
+
162
+ super(@output)
163
+
164
+ @thread = Thread.new do
165
+ @input.each{|line| logger.send(level, line.chomp!)}
166
+ end
167
+ end
168
+
169
+ def close
170
+ # Close the output pipe, we should never be writing to this anyway:
171
+ @output.close
172
+
173
+ # Wait for the thread to read everything and join:
174
+ @thread.join
175
+
176
+ # Close the input pipe because it's already closed on the remote end:
177
+ @input.close
178
+ end
179
+ end
180
+
181
+ class ServerScope < DelegateClass(Server)
182
+ def initialize(server, script_scope, from = nil)
183
+ super(server)
184
+
185
+ @script_scope = script_scope
186
+ @from = from
187
+ end
188
+
189
+ def logger
190
+ @logger ||= @script_scope.logger
191
+ end
192
+
193
+ def group
194
+ @group ||= @script_scope.group
195
+ end
196
+
197
+ def run(*command, from: @from, **options)
198
+ # We are invoking a command from the given server, so we need to use the shell to connect..
199
+ if from and !from.same_host?(self)
200
+ if chdir = options.delete(:chdir)
201
+ command = ["synco", "--root", chdir, "spawn"] + command
202
+ end
203
+
204
+ command = self.connection_command + ["--"] + command
205
+ end
206
+
207
+ logger.info("shell") {[command, options]}
208
+
209
+ options[:out] ||= LogPipe.new(logger)
210
+ options[:err] ||= LogPipe.new(logger, :error)
211
+
212
+ status = self.group.spawn(*command, **options)
213
+ logger.debug{"Process finished: #{status}."}
214
+
215
+ options[:out].close
216
+ options[:err].close
217
+
218
+ unless status.success?
219
+ raise CommandFailure.new(command, status)
220
+ end
221
+ end
222
+ end
223
+
224
+ class TargetScope < DelegateClass(ScriptScope)
225
+ def initialize(script_scope, target)
226
+ super(script_scope)
227
+
228
+ @target_server = ServerScope.new(target, script_scope, script_scope.current_server)
229
+ end
230
+
231
+ def run(*arguments)
232
+ @target_server.run(*arguments)
233
+ end
234
+
235
+ attr :target_server
236
+ end
237
+
238
+ class DirectoryScope < DelegateClass(TargetScope)
239
+ def initialize(sync_scope, directory)
240
+ super(sync_scope)
241
+
242
+ @directory = directory
243
+ end
244
+
245
+ attr :directory
246
+ end
247
+ end
@@ -0,0 +1,128 @@
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_relative 'server'
23
+ require_relative 'directory'
24
+ require_relative 'controller'
25
+
26
+ require_relative 'compact_formatter'
27
+
28
+ require 'socket'
29
+
30
+ require 'process/group'
31
+
32
+ module Synco
33
+ # The main backup/synchronisation mechanism is the backup script. It specifies all servers and directories, and these are then combined specifically to produce the desired data replication behaviour.
34
+ class Script < Controller
35
+ def initialize(method: nil, servers: {}, directories: [], master: :master, logger: nil)
36
+ super()
37
+
38
+ @method = method
39
+ @servers = servers
40
+ @directories = directories
41
+ @master = master
42
+ end
43
+
44
+ def freeze
45
+ current_server; master_server
46
+
47
+ super
48
+ end
49
+
50
+ def running_on_master?
51
+ current_server.same_host?(master_server)
52
+ end
53
+
54
+ def resolve_name(name)
55
+ Socket.gethostbyname(name)[0]
56
+ end
57
+
58
+ def localhost?(name)
59
+ return true if name == "localhost"
60
+
61
+ host = resolve_name(Socket.gethostname)
62
+
63
+ return name == host
64
+ end
65
+
66
+ # Given a name, find out which server config matches it.
67
+ def find_named_server(name)
68
+ if @servers.key? name
69
+ @servers[name]
70
+ else
71
+ host = resolve_name(name)
72
+ @servers.values.find{|server| server.host == host}
73
+ end
74
+ end
75
+
76
+ alias :[] :find_named_server
77
+
78
+ # The master server based on the name #master= specified
79
+ def master_server
80
+ @master_server ||= find_named_server(@master)
81
+ end
82
+
83
+ # Find the server that matches the current machine
84
+ def find_current_server
85
+ # There might be the case that the the local machine is both the master server and the backup server..
86
+ # thus we check first if the master server is the local machine:
87
+ if master_server and localhost?(master_server.host)
88
+ @master_server
89
+ else
90
+ # Find a server config that specifies the local host
91
+ @servers.values.find{|server| localhost?(server.host)}
92
+ end || Server.new('localhost')
93
+ end
94
+
95
+ def current_server
96
+ @current_server ||= find_current_server
97
+ end
98
+
99
+ # Register a server with the backup script.
100
+ def server(*arguments, **options, &block)
101
+ server = Server.build(*arguments, **options, &block)
102
+ @servers[server.name] = server
103
+ end
104
+
105
+ # Backup a particular path (or paths).
106
+ def directories(*paths, **options, &block)
107
+ paths.each do |path|
108
+ @directories << Directory.build(path, **options, &block)
109
+ end
110
+ end
111
+
112
+ alias :copy :directories
113
+ alias :backup :directories
114
+ alias :sync :directories
115
+
116
+ # The master server name (e.g. symbolic or host name)
117
+ attr_accessor :master
118
+
119
+ # A specific method which will perform the backup (e.g. an instance of Synco::Method)
120
+ attr_accessor :method
121
+
122
+ # All servers which are participating in the backup process.
123
+ attr_accessor :servers
124
+
125
+ # All directories which may be synchronised.
126
+ attr_accessor :directories
127
+ end
128
+ end