backup 3.3.2 → 3.4.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.
@@ -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