backup 3.3.2 → 3.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/backup/cleaner.rb +22 -29
- data/lib/backup/database/base.rb +5 -1
- data/lib/backup/model.rb +4 -4
- data/lib/backup/package.rb +3 -4
- data/lib/backup/splitter.rb +12 -14
- data/lib/backup/storage/base.rb +25 -53
- data/lib/backup/storage/cloudfiles.rb +18 -38
- data/lib/backup/storage/cycler.rb +6 -6
- data/lib/backup/storage/dropbox.rb +41 -55
- data/lib/backup/storage/ftp.rb +17 -37
- data/lib/backup/storage/local.rb +23 -42
- data/lib/backup/storage/ninefold.rb +28 -69
- data/lib/backup/storage/rsync.rb +18 -24
- data/lib/backup/storage/s3.rb +166 -48
- data/lib/backup/storage/scp.rb +13 -41
- data/lib/backup/storage/sftp.rb +16 -37
- data/lib/backup/version.rb +1 -1
- data/templates/general/links +3 -11
- metadata +3 -3
data/lib/backup/storage/rsync.rb
CHANGED
@@ -145,46 +145,40 @@ module Backup
|
|
145
145
|
private
|
146
146
|
|
147
147
|
def transfer!
|
148
|
-
|
148
|
+
write_password_file
|
149
|
+
create_remote_path
|
149
150
|
|
150
|
-
|
151
|
-
|
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
|
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
|
177
|
-
# be stored directly in #path.
|
178
|
-
def
|
179
|
-
@
|
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(/^~\//, ''),
|
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),
|
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
|
194
|
+
def create_remote_path
|
201
195
|
if host
|
202
196
|
run("#{ utility(:ssh) } #{ ssh_transport_args } #{ host } " +
|
203
|
-
%Q["mkdir -p '#{
|
197
|
+
%Q["mkdir -p '#{ remote_path }'"]) if mode == :ssh
|
204
198
|
else
|
205
|
-
FileUtils.mkdir_p
|
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
|
|
data/lib/backup/storage/s3.rb
CHANGED
@@ -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
|
17
|
-
attr_accessor :bucket
|
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
|
-
#
|
25
|
-
|
26
|
-
|
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
|
-
|
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
|
-
|
37
|
-
|
38
|
-
|
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
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
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
|
-
|
53
|
-
|
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
|
-
|
58
|
-
|
59
|
-
|
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
|
-
|
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
|
-
|
64
|
-
Logger.info "#{storage_name} started transferring " +
|
65
|
-
"'#{ local_file }' to bucket '#{ bucket }'."
|
139
|
+
private
|
66
140
|
|
67
|
-
|
68
|
-
|
69
|
-
|
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
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
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
|
-
|
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
|
-
|
85
|
-
|
86
|
-
|
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
|
-
|
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
|
-
|
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
|
data/lib/backup/storage/scp.rb
CHANGED
@@ -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
|
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
|
-
|
55
|
-
|
56
|
-
|
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
|
-
#
|
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
|
-
|
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 '#{
|
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
|
-
|
60
|
+
"Net::SSH reported the following errors:\n" +
|
89
61
|
errors.join("\n")
|
90
62
|
end
|
91
63
|
end
|
data/lib/backup/storage/sftp.rb
CHANGED
@@ -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
|
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(
|
38
|
+
create_remote_path(sftp)
|
53
39
|
|
54
|
-
|
55
|
-
|
56
|
-
|
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
|
-
#
|
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
|
-
|
52
|
+
Logger.info "Removing backup package dated #{ package.time }..."
|
72
53
|
|
54
|
+
remote_path = remote_path_for(package)
|
73
55
|
connection do |sftp|
|
74
|
-
|
75
|
-
|
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(
|
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
|