backup 3.3.2 → 3.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -145,46 +145,40 @@ module Backup
145
145
  private
146
146
 
147
147
  def transfer!
148
- Logger.info "#{ storage_name } Started..."
148
+ write_password_file
149
+ create_remote_path
149
150
 
150
- write_password_file!
151
- create_dest_path!
152
-
153
- files_to_transfer_for(@package) do |local_file, remote_file|
154
- src = "'#{ File.join(local_path, local_file) }'"
155
- dest = "#{ host_options }'#{ File.join(dest_path, remote_file) }'"
151
+ package.filenames.each do |filename|
152
+ src = "'#{ File.join(Config.tmp_path, filename) }'"
153
+ dest = "#{ host_options }'#{ File.join(remote_path, filename) }'"
156
154
  Logger.info "Syncing to #{ dest }..."
157
155
  run("#{ rsync_command } #{ src } #{ dest }")
158
156
  end
159
-
160
- Logger.info "#{ storage_name } Finished!"
161
157
  ensure
162
- remove_password_file!
158
+ remove_password_file
163
159
  end
164
160
 
165
- ##
166
161
  # Storage::RSync doesn't cycle
167
162
  def cycle!; end
168
163
 
169
164
  ##
170
- # Other storages use #remote_path_for to set the dest_path,
171
- # which adds an additional timestamp directory to the path.
165
+ # Other storages add an additional timestamp directory to this path.
172
166
  # This is not desired here, since we need to transfer the package files
173
167
  # to the same location each time.
174
168
  #
175
169
  # Note: In v4.0, the additional trigger directory will to be dropped
176
- # from dest_path for both local and :ssh mode, so the package files will
177
- # be stored directly in #path.
178
- def dest_path
179
- @dest_path ||= begin
170
+ # from remote_path for both local and :ssh mode, so the package files
171
+ # will be stored directly in #path.
172
+ def remote_path
173
+ @remote_path ||= begin
180
174
  if host
181
175
  if mode == :ssh
182
- File.join(path.sub(/^~\//, ''), @package.trigger)
176
+ File.join(path.sub(/^~\//, ''), package.trigger)
183
177
  else
184
178
  path.sub(/^~\//, '').sub(/\/$/, '')
185
179
  end
186
180
  else
187
- File.join(File.expand_path(path), @package.trigger)
181
+ File.join(File.expand_path(path), package.trigger)
188
182
  end
189
183
  end
190
184
  end
@@ -197,12 +191,12 @@ module Backup
197
191
  # This is only applicable locally and in :ssh mode.
198
192
  # In :ssh_daemon and :rsync_daemon modes the `path` would include a
199
193
  # module name that must define a path on the remote that already exists.
200
- def create_dest_path!
194
+ def create_remote_path
201
195
  if host
202
196
  run("#{ utility(:ssh) } #{ ssh_transport_args } #{ host } " +
203
- %Q["mkdir -p '#{ dest_path }'"]) if mode == :ssh
197
+ %Q["mkdir -p '#{ remote_path }'"]) if mode == :ssh
204
198
  else
205
- FileUtils.mkdir_p dest_path
199
+ FileUtils.mkdir_p(remote_path)
206
200
  end
207
201
  end
208
202
 
@@ -254,7 +248,7 @@ module Backup
254
248
  args.rstrip
255
249
  end
256
250
 
257
- def write_password_file!
251
+ def write_password_file
258
252
  return unless host && rsync_password && mode != :ssh
259
253
 
260
254
  @password_file = Tempfile.new('backup-rsync-password')
@@ -262,7 +256,7 @@ module Backup
262
256
  @password_file.close
263
257
  end
264
258
 
265
- def remove_password_file!
259
+ def remove_password_file
266
260
  @password_file.delete if @password_file
267
261
  end
268
262
 
@@ -1,8 +1,8 @@
1
1
  # encoding: utf-8
2
2
 
3
- ##
4
- # Only load the Fog gem when the Backup::Storage::S3 class is loaded
5
3
  Backup::Dependency.load('fog')
4
+ require 'base64'
5
+ require 'digest/md5'
6
6
 
7
7
  module Backup
8
8
  module Storage
@@ -13,81 +13,199 @@ module Backup
13
13
  attr_accessor :access_key_id, :secret_access_key
14
14
 
15
15
  ##
16
- # Amazon S3 bucket name and path
17
- attr_accessor :bucket, :path
16
+ # Amazon S3 bucket name
17
+ attr_accessor :bucket
18
18
 
19
19
  ##
20
20
  # Region of the specified S3 bucket
21
21
  attr_accessor :region
22
22
 
23
23
  ##
24
- # Creates a new instance of the storage object
25
- def initialize(model, storage_id = nil, &block)
26
- super(model, storage_id)
24
+ # Chunk size, specified in MiB, for S3 Multipart Upload.
25
+ #
26
+ # Each backup package file that is greater than +chunk_size+
27
+ # will be uploaded using AWS' Multipart Upload.
28
+ #
29
+ # Package files less than or equal to +chunk_size+ will be
30
+ # uploaded via a single PUT request.
31
+ #
32
+ # Minimum allowed: 5 (but may be disabled with 0)
33
+ # Default: 5
34
+ attr_accessor :chunk_size
27
35
 
28
- @path ||= 'backups'
36
+ ##
37
+ # Number of times to retry failed operations.
38
+ #
39
+ # The retry count is reset when the failing operation succeeds,
40
+ # so each operation that fails will be retried this number of times.
41
+ # Once a single failed operation exceeds +max_retries+, the entire
42
+ # storage operation will fail.
43
+ #
44
+ # Operations that may fail and be retried include:
45
+ # - Multipart initiation requests.
46
+ # - Each multipart upload of +chunk_size+. (retries the chunk)
47
+ # - Multipart upload completion requests.
48
+ # - Each file uploaded not using multipart upload. (retries the file)
49
+ #
50
+ # Default: 10
51
+ attr_accessor :max_retries
29
52
 
53
+ ##
54
+ # Time in seconds to pause before each retry.
55
+ #
56
+ # Default: 30
57
+ attr_accessor :retry_waitsec
58
+
59
+ def initialize(model, storage_id = nil, &block)
60
+ super
30
61
  instance_eval(&block) if block_given?
62
+
63
+ @chunk_size ||= 5 # MiB
64
+ @max_retries ||= 10
65
+ @retry_waitsec ||= 30
66
+ @path ||= 'backups'
67
+ path.sub!(/^\//, '')
31
68
  end
32
69
 
33
70
  private
34
71
 
35
- ##
36
- # This is the provider that Fog uses for the S3 Storage
37
- def provider
38
- 'AWS'
72
+ def connection
73
+ @connection ||= begin
74
+ conn = Fog::Storage.new(
75
+ :provider => 'AWS',
76
+ :aws_access_key_id => access_key_id,
77
+ :aws_secret_access_key => secret_access_key,
78
+ :region => region
79
+ )
80
+ conn.sync_clock
81
+ conn
82
+ end
39
83
  end
40
84
 
41
- ##
42
- # Establishes a connection to Amazon S3
43
- def connection
44
- @connection ||= Fog::Storage.new(
45
- :provider => provider,
46
- :aws_access_key_id => access_key_id,
47
- :aws_secret_access_key => secret_access_key,
48
- :region => region
49
- )
85
+ def transfer!
86
+ package.filenames.each do |filename|
87
+ src = File.join(Config.tmp_path, filename)
88
+ dest = File.join(remote_path, filename)
89
+ Logger.info "Storing '#{ bucket }/#{ dest }'..."
90
+ Uploader.new(connection, bucket, src, dest,
91
+ chunk_size, max_retries, retry_waitsec).run
92
+ end
50
93
  end
51
94
 
52
- def remote_path_for(package)
53
- super(package).sub(/^\//, '')
95
+ # Called by the Cycler.
96
+ # Any error raised will be logged as a warning.
97
+ def remove!(package)
98
+ Logger.info "Removing backup package dated #{ package.time }..."
99
+
100
+ remote_path = remote_path_for(package)
101
+ resp = connection.get_bucket(bucket, :prefix => remote_path)
102
+ keys = resp.body['Contents'].map {|entry| entry['Key'] }
103
+
104
+ raise Errors::Storage::S3::NotFoundError,
105
+ "Package at '#{ remote_path }' not found" if keys.empty?
106
+
107
+ connection.delete_multiple_objects(bucket, keys)
54
108
  end
55
109
 
56
- ##
57
- # Transfers the archived file to the specified Amazon S3 bucket
58
- def transfer!
59
- remote_path = remote_path_for(@package)
110
+ class Uploader
111
+ attr_reader :connection, :bucket, :src, :dest
112
+ attr_reader :chunk_size, :max_retries, :retry_waitsec
113
+ attr_reader :upload_id, :parts
114
+
115
+ def initialize(connection, bucket, src, dest,
116
+ chunk_size, max_retries, retry_waitsec)
117
+ @connection = connection
118
+ @bucket = bucket
119
+ @src = src
120
+ @dest = dest
121
+ @chunk_size = 1024**2 * chunk_size
122
+ @max_retries = max_retries
123
+ @retry_waitsec = retry_waitsec
124
+ @parts = []
125
+ end
60
126
 
61
- connection.sync_clock
127
+ def run
128
+ if chunk_size > 0 && File.size(src) > chunk_size
129
+ initiate_multipart
130
+ upload_parts
131
+ complete_multipart
132
+ else
133
+ upload
134
+ end
135
+ rescue => err
136
+ raise error_with(err, 'Upload Failed!')
137
+ end
62
138
 
63
- files_to_transfer_for(@package) do |local_file, remote_file|
64
- Logger.info "#{storage_name} started transferring " +
65
- "'#{ local_file }' to bucket '#{ bucket }'."
139
+ private
66
140
 
67
- File.open(File.join(local_path, local_file), 'r') do |file|
68
- connection.put_object(
69
- bucket, File.join(remote_path, remote_file), file
70
- )
141
+ def upload
142
+ md5 = Base64.encode64(Digest::MD5.file(src).digest).chomp
143
+ with_retries do
144
+ File.open(src, 'r') do |file|
145
+ connection.put_object(bucket, dest, file, { 'Content-MD5' => md5 })
146
+ end
71
147
  end
72
148
  end
73
- end
74
149
 
75
- ##
76
- # Removes the transferred archive file(s) from the storage location.
77
- # Any error raised will be rescued during Cycling
78
- # and a warning will be logged, containing the error message.
79
- def remove!(package)
80
- remote_path = remote_path_for(package)
150
+ def initiate_multipart
151
+ with_retries do
152
+ resp = connection.initiate_multipart_upload(bucket, dest)
153
+ @upload_id = resp.body['UploadId']
154
+ end
155
+ end
81
156
 
82
- connection.sync_clock
157
+ def upload_parts
158
+ File.open(src, 'r') do |file|
159
+ part_number = 0
160
+ while data = file.read(chunk_size)
161
+ part_number += 1
162
+ md5 = Base64.encode64(Digest::MD5.digest(data)).chomp
163
+ with_retries do
164
+ resp = connection.upload_part(
165
+ bucket, dest, upload_id, part_number, data,
166
+ { 'Content-MD5' => md5 }
167
+ )
168
+ parts << resp.headers['ETag']
169
+ end
170
+ end
171
+ end
172
+ end
83
173
 
84
- transferred_files_for(package) do |local_file, remote_file|
85
- Logger.info "#{storage_name} started removing " +
86
- "'#{ local_file }' from bucket '#{ bucket }'."
174
+ def complete_multipart
175
+ with_retries do
176
+ connection.complete_multipart_upload(bucket, dest, upload_id, parts)
177
+ end
178
+ end
87
179
 
88
- connection.delete_object(bucket, File.join(remote_path, remote_file))
180
+ def with_retries
181
+ retries = 0
182
+ begin
183
+ yield
184
+ rescue => err
185
+ retries += 1
186
+ raise if retries > max_retries
187
+
188
+ Logger.info error_with(err, "Retry ##{ retries } of #{ max_retries }.")
189
+ sleep(retry_waitsec)
190
+ retry
191
+ end
89
192
  end
90
- end
193
+
194
+ # Avoid wrapping Excon::Errors::HTTPStatusError since it's message
195
+ # includes `request.inspect`. For multipart uploads, this includes
196
+ # the String#inspect output of `file.read(chunk_size)`.
197
+ def error_with(err, msg)
198
+ if err.is_a? Excon::Errors::HTTPStatusError
199
+ Errors::Storage::S3::UploaderError.new(<<-EOS)
200
+ #{ msg }
201
+ Reason: #{ err.class }
202
+ response => #{ err.response.inspect }
203
+ EOS
204
+ else
205
+ Errors::Storage::S3::UploaderError.wrap(err, msg)
206
+ end
207
+ end
208
+ end # class Uploader
91
209
 
92
210
  end
93
211
  end
@@ -14,78 +14,50 @@ module Backup
14
14
  # Server IP Address and SCP port
15
15
  attr_accessor :ip, :port
16
16
 
17
- ##
18
- # Path to store backups to
19
- attr_accessor :path
20
-
21
- ##
22
- # Creates a new instance of the storage object
23
17
  def initialize(model, storage_id = nil, &block)
24
- super(model, storage_id)
18
+ super
19
+ instance_eval(&block) if block_given?
25
20
 
26
21
  @port ||= 22
27
22
  @path ||= 'backups'
28
-
29
- instance_eval(&block) if block_given?
30
-
31
- @path = path.sub(/^\~\//, '')
23
+ path.sub!(/^~\//, '')
32
24
  end
33
25
 
34
26
  private
35
27
 
36
- ##
37
- # Establishes a connection to the remote server
38
- # and yields the Net::SSH connection.
39
- # Net::SCP will use this connection to transfer backups
40
28
  def connection
41
29
  Net::SSH.start(
42
30
  ip, username, :password => password, :port => port
43
31
  ) {|ssh| yield ssh }
44
32
  end
45
33
 
46
- ##
47
- # Transfers the archived file to the specified remote server
48
34
  def transfer!
49
- remote_path = remote_path_for(@package)
50
-
51
35
  connection do |ssh|
52
36
  ssh.exec!("mkdir -p '#{ remote_path }'")
53
37
 
54
- files_to_transfer_for(@package) do |local_file, remote_file|
55
- Logger.info "#{storage_name} started transferring " +
56
- "'#{local_file}' to '#{ip}'."
57
-
58
- ssh.scp.upload!(
59
- File.join(local_path, local_file),
60
- File.join(remote_path, remote_file)
61
- )
38
+ package.filenames.each do |filename|
39
+ src = File.join(Config.tmp_path, filename)
40
+ dest = File.join(remote_path, filename)
41
+ Logger.info "Storing '#{ ip }:#{ dest }'..."
42
+ ssh.scp.upload!(src, dest)
62
43
  end
63
44
  end
64
45
  end
65
46
 
66
- ##
67
- # Removes the transferred archive file(s) from the storage location.
68
- # Any error raised will be rescued during Cycling
69
- # and a warning will be logged, containing the error message.
47
+ # Called by the Cycler.
48
+ # Any error raised will be logged as a warning.
70
49
  def remove!(package)
71
- remote_path = remote_path_for(package)
72
-
73
- messages = []
74
- transferred_files_for(package) do |local_file, remote_file|
75
- messages << "#{storage_name} started removing " +
76
- "'#{local_file}' from '#{ip}'."
77
- end
78
- Logger.info messages.join("\n")
50
+ Logger.info "Removing backup package dated #{ package.time }..."
79
51
 
80
52
  errors = []
81
53
  connection do |ssh|
82
- ssh.exec!("rm -r '#{remote_path}'") do |ch, stream, data|
54
+ ssh.exec!("rm -r '#{ remote_path_for(package) }'") do |ch, stream, data|
83
55
  errors << data if stream == :stderr
84
56
  end
85
57
  end
86
58
  unless errors.empty?
87
59
  raise Errors::Storage::SCP::SSHError,
88
- "Net::SSH reported the following errors:\n" +
60
+ "Net::SSH reported the following errors:\n" +
89
61
  errors.join("\n")
90
62
  end
91
63
  end
@@ -14,27 +14,17 @@ module Backup
14
14
  # Server IP Address and SFTP port
15
15
  attr_accessor :ip, :port
16
16
 
17
- ##
18
- # Path to store backups to
19
- attr_accessor :path
20
-
21
- ##
22
- # Creates a new instance of the storage object
23
17
  def initialize(model, storage_id = nil, &block)
24
- super(model, storage_id)
18
+ super
19
+ instance_eval(&block) if block_given?
25
20
 
26
21
  @port ||= 22
27
22
  @path ||= 'backups'
28
-
29
- instance_eval(&block) if block_given?
30
-
31
- @path = path.sub(/^\~\//, '')
23
+ path.sub!(/^~\//, '')
32
24
  end
33
25
 
34
26
  private
35
27
 
36
- ##
37
- # Establishes a connection to the remote server
38
28
  def connection
39
29
  Net::SFTP.start(
40
30
  ip, username,
@@ -43,39 +33,28 @@ module Backup
43
33
  ) {|sftp| yield sftp }
44
34
  end
45
35
 
46
- ##
47
- # Transfers the archived file to the specified remote server
48
36
  def transfer!
49
- remote_path = remote_path_for(@package)
50
-
51
37
  connection do |sftp|
52
- create_remote_path(remote_path, sftp)
38
+ create_remote_path(sftp)
53
39
 
54
- files_to_transfer_for(@package) do |local_file, remote_file|
55
- Logger.info "#{storage_name} started transferring " +
56
- "'#{ local_file }' to '#{ ip }'."
57
-
58
- sftp.upload!(
59
- File.join(local_path, local_file),
60
- File.join(remote_path, remote_file)
61
- )
40
+ package.filenames.each do |filename|
41
+ src = File.join(Config.tmp_path, filename)
42
+ dest = File.join(remote_path, filename)
43
+ Logger.info "Storing '#{ ip }:#{ dest }'..."
44
+ sftp.upload!(src, dest)
62
45
  end
63
46
  end
64
47
  end
65
48
 
66
- ##
67
- # Removes the transferred archive file(s) from the storage location.
68
- # Any error raised will be rescued during Cycling
69
- # and a warning will be logged, containing the error message.
49
+ # Called by the Cycler.
50
+ # Any error raised will be logged as a warning.
70
51
  def remove!(package)
71
- remote_path = remote_path_for(package)
52
+ Logger.info "Removing backup package dated #{ package.time }..."
72
53
 
54
+ remote_path = remote_path_for(package)
73
55
  connection do |sftp|
74
- transferred_files_for(package) do |local_file, remote_file|
75
- Logger.info "#{storage_name} started removing " +
76
- "'#{ local_file }' from '#{ ip }'."
77
-
78
- sftp.remove!(File.join(remote_path, remote_file))
56
+ package.filenames.each do |filename|
57
+ sftp.remove!(File.join(remote_path, filename))
79
58
  end
80
59
 
81
60
  sftp.rmdir!(remote_path)
@@ -90,7 +69,7 @@ module Backup
90
69
  # '/') and loop through that to create the directories one by one.
91
70
  # Net::SFTP raises an exception when the directory it's trying to create
92
71
  # already exists, so we have rescue it
93
- def create_remote_path(remote_path, sftp)
72
+ def create_remote_path(sftp)
94
73
  path_parts = Array.new
95
74
  remote_path.split('/').each do |path_part|
96
75
  path_parts << path_part