backupii 0.1.0.pre.alpha.1

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.
Files changed (135) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +19 -0
  3. data/README.md +37 -0
  4. data/bin/backupii +5 -0
  5. data/bin/docker_test +24 -0
  6. data/lib/backup/archive.rb +171 -0
  7. data/lib/backup/binder.rb +23 -0
  8. data/lib/backup/cleaner.rb +114 -0
  9. data/lib/backup/cli.rb +376 -0
  10. data/lib/backup/cloud_io/base.rb +40 -0
  11. data/lib/backup/cloud_io/cloud_files.rb +301 -0
  12. data/lib/backup/cloud_io/s3.rb +256 -0
  13. data/lib/backup/compressor/base.rb +34 -0
  14. data/lib/backup/compressor/bzip2.rb +37 -0
  15. data/lib/backup/compressor/custom.rb +51 -0
  16. data/lib/backup/compressor/gzip.rb +76 -0
  17. data/lib/backup/config/dsl.rb +103 -0
  18. data/lib/backup/config/helpers.rb +139 -0
  19. data/lib/backup/config.rb +122 -0
  20. data/lib/backup/database/base.rb +89 -0
  21. data/lib/backup/database/mongodb.rb +189 -0
  22. data/lib/backup/database/mysql.rb +194 -0
  23. data/lib/backup/database/openldap.rb +97 -0
  24. data/lib/backup/database/postgresql.rb +134 -0
  25. data/lib/backup/database/redis.rb +179 -0
  26. data/lib/backup/database/riak.rb +82 -0
  27. data/lib/backup/database/sqlite.rb +57 -0
  28. data/lib/backup/encryptor/base.rb +29 -0
  29. data/lib/backup/encryptor/gpg.rb +745 -0
  30. data/lib/backup/encryptor/open_ssl.rb +76 -0
  31. data/lib/backup/errors.rb +55 -0
  32. data/lib/backup/logger/console.rb +50 -0
  33. data/lib/backup/logger/fog_adapter.rb +27 -0
  34. data/lib/backup/logger/logfile.rb +134 -0
  35. data/lib/backup/logger/syslog.rb +116 -0
  36. data/lib/backup/logger.rb +199 -0
  37. data/lib/backup/model.rb +478 -0
  38. data/lib/backup/notifier/base.rb +128 -0
  39. data/lib/backup/notifier/campfire.rb +63 -0
  40. data/lib/backup/notifier/command.rb +101 -0
  41. data/lib/backup/notifier/datadog.rb +107 -0
  42. data/lib/backup/notifier/flowdock.rb +101 -0
  43. data/lib/backup/notifier/hipchat.rb +118 -0
  44. data/lib/backup/notifier/http_post.rb +116 -0
  45. data/lib/backup/notifier/mail.rb +235 -0
  46. data/lib/backup/notifier/nagios.rb +67 -0
  47. data/lib/backup/notifier/pagerduty.rb +82 -0
  48. data/lib/backup/notifier/prowl.rb +70 -0
  49. data/lib/backup/notifier/pushover.rb +73 -0
  50. data/lib/backup/notifier/ses.rb +126 -0
  51. data/lib/backup/notifier/slack.rb +149 -0
  52. data/lib/backup/notifier/twitter.rb +57 -0
  53. data/lib/backup/notifier/zabbix.rb +62 -0
  54. data/lib/backup/package.rb +53 -0
  55. data/lib/backup/packager.rb +108 -0
  56. data/lib/backup/pipeline.rb +122 -0
  57. data/lib/backup/splitter.rb +75 -0
  58. data/lib/backup/storage/base.rb +72 -0
  59. data/lib/backup/storage/cloud_files.rb +158 -0
  60. data/lib/backup/storage/cycler.rb +73 -0
  61. data/lib/backup/storage/dropbox.rb +208 -0
  62. data/lib/backup/storage/ftp.rb +118 -0
  63. data/lib/backup/storage/local.rb +63 -0
  64. data/lib/backup/storage/qiniu.rb +68 -0
  65. data/lib/backup/storage/rsync.rb +251 -0
  66. data/lib/backup/storage/s3.rb +157 -0
  67. data/lib/backup/storage/scp.rb +67 -0
  68. data/lib/backup/storage/sftp.rb +82 -0
  69. data/lib/backup/syncer/base.rb +70 -0
  70. data/lib/backup/syncer/cloud/base.rb +180 -0
  71. data/lib/backup/syncer/cloud/cloud_files.rb +83 -0
  72. data/lib/backup/syncer/cloud/local_file.rb +99 -0
  73. data/lib/backup/syncer/cloud/s3.rb +118 -0
  74. data/lib/backup/syncer/rsync/base.rb +55 -0
  75. data/lib/backup/syncer/rsync/local.rb +29 -0
  76. data/lib/backup/syncer/rsync/pull.rb +49 -0
  77. data/lib/backup/syncer/rsync/push.rb +206 -0
  78. data/lib/backup/template.rb +45 -0
  79. data/lib/backup/utilities.rb +235 -0
  80. data/lib/backup/version.rb +5 -0
  81. data/lib/backup.rb +141 -0
  82. data/templates/cli/archive +28 -0
  83. data/templates/cli/compressor/bzip2 +4 -0
  84. data/templates/cli/compressor/custom +7 -0
  85. data/templates/cli/compressor/gzip +4 -0
  86. data/templates/cli/config +123 -0
  87. data/templates/cli/databases/mongodb +15 -0
  88. data/templates/cli/databases/mysql +18 -0
  89. data/templates/cli/databases/openldap +24 -0
  90. data/templates/cli/databases/postgresql +16 -0
  91. data/templates/cli/databases/redis +16 -0
  92. data/templates/cli/databases/riak +17 -0
  93. data/templates/cli/databases/sqlite +11 -0
  94. data/templates/cli/encryptor/gpg +27 -0
  95. data/templates/cli/encryptor/openssl +9 -0
  96. data/templates/cli/model +26 -0
  97. data/templates/cli/notifier/zabbix +15 -0
  98. data/templates/cli/notifiers/campfire +12 -0
  99. data/templates/cli/notifiers/command +32 -0
  100. data/templates/cli/notifiers/datadog +57 -0
  101. data/templates/cli/notifiers/flowdock +16 -0
  102. data/templates/cli/notifiers/hipchat +16 -0
  103. data/templates/cli/notifiers/http_post +32 -0
  104. data/templates/cli/notifiers/mail +24 -0
  105. data/templates/cli/notifiers/nagios +13 -0
  106. data/templates/cli/notifiers/pagerduty +12 -0
  107. data/templates/cli/notifiers/prowl +11 -0
  108. data/templates/cli/notifiers/pushover +11 -0
  109. data/templates/cli/notifiers/ses +15 -0
  110. data/templates/cli/notifiers/slack +22 -0
  111. data/templates/cli/notifiers/twitter +13 -0
  112. data/templates/cli/splitter +7 -0
  113. data/templates/cli/storages/cloud_files +11 -0
  114. data/templates/cli/storages/dropbox +20 -0
  115. data/templates/cli/storages/ftp +13 -0
  116. data/templates/cli/storages/local +8 -0
  117. data/templates/cli/storages/qiniu +12 -0
  118. data/templates/cli/storages/rsync +17 -0
  119. data/templates/cli/storages/s3 +16 -0
  120. data/templates/cli/storages/scp +15 -0
  121. data/templates/cli/storages/sftp +15 -0
  122. data/templates/cli/syncers/cloud_files +22 -0
  123. data/templates/cli/syncers/rsync_local +20 -0
  124. data/templates/cli/syncers/rsync_pull +28 -0
  125. data/templates/cli/syncers/rsync_push +28 -0
  126. data/templates/cli/syncers/s3 +27 -0
  127. data/templates/general/links +3 -0
  128. data/templates/general/version.erb +2 -0
  129. data/templates/notifier/mail/failure.erb +16 -0
  130. data/templates/notifier/mail/success.erb +16 -0
  131. data/templates/notifier/mail/warning.erb +16 -0
  132. data/templates/storage/dropbox/authorization_url.erb +6 -0
  133. data/templates/storage/dropbox/authorized.erb +4 -0
  134. data/templates/storage/dropbox/cache_file_written.erb +10 -0
  135. metadata +507 -0
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "backup/cloud_io/base"
4
+ require "fog"
5
+ require "digest/md5"
6
+
7
+ module Backup
8
+ module CloudIO
9
+ class CloudFiles < Base
10
+ class Error < Backup::Error; end
11
+
12
+ MAX_FILE_SIZE = 1024**3 * 5 # 5 GiB
13
+ MAX_SLO_SIZE = 1024**3 * 5000 # 1000 segments @ 5 GiB
14
+ SEGMENT_BUFFER = 1024**2 # 1 MiB
15
+
16
+ attr_reader :username, :api_key, :auth_url, :region, :servicenet,
17
+ :container, :segments_container, :segment_size, :days_to_keep,
18
+ :fog_options
19
+
20
+ def initialize(options = {})
21
+ super
22
+
23
+ @username = options[:username]
24
+ @api_key = options[:api_key]
25
+ @auth_url = options[:auth_url]
26
+ @region = options[:region]
27
+ @servicenet = options[:servicenet]
28
+ @container = options[:container]
29
+ @segments_container = options[:segments_container]
30
+ @segment_size = options[:segment_size]
31
+ @days_to_keep = options[:days_to_keep]
32
+ @fog_options = options[:fog_options]
33
+ end
34
+
35
+ # The Syncer may call this method in multiple threads,
36
+ # but #objects is always called before this occurs.
37
+ def upload(src, dest)
38
+ create_containers
39
+
40
+ file_size = File.size(src)
41
+ segment_bytes = segment_size * 1024**2
42
+ if segment_bytes > 0 && file_size > segment_bytes
43
+ raise FileSizeError, <<-EOS if file_size > MAX_SLO_SIZE
44
+ File Too Large
45
+ File: #{src}
46
+ Size: #{file_size}
47
+ Max SLO Size is #{MAX_SLO_SIZE} (5 GiB * 1000 segments)
48
+ EOS
49
+
50
+ segment_bytes = adjusted_segment_bytes(segment_bytes, file_size)
51
+ segments = upload_segments(src, dest, segment_bytes, file_size)
52
+ upload_manifest(dest, segments)
53
+ else
54
+ raise FileSizeError, <<-EOS if file_size > MAX_FILE_SIZE
55
+ File Too Large
56
+ File: #{src}
57
+ Size: #{file_size}
58
+ Max File Size is #{MAX_FILE_SIZE} (5 GiB)
59
+ EOS
60
+
61
+ put_object(src, dest)
62
+ end
63
+ end
64
+
65
+ # Returns all objects in the container with the given prefix.
66
+ #
67
+ # - #get_container returns a max of 10000 objects per request.
68
+ # - Returns objects sorted using a sqlite binary collating function.
69
+ # - If marker is given, only objects after the marker are in the response.
70
+ def objects(prefix)
71
+ objects = []
72
+ resp = nil
73
+ prefix = prefix.chomp("/")
74
+ opts = { prefix: prefix + "/" }
75
+
76
+ create_containers
77
+
78
+ while resp.nil? || resp.body.count == 10_000
79
+ opts[:marker] = objects.last.name unless objects.empty?
80
+ with_retries("GET '#{container}/#{prefix}/*'") do
81
+ resp = connection.get_container(container, opts)
82
+ end
83
+ resp.body.each do |obj_data|
84
+ objects << Object.new(self, obj_data)
85
+ end
86
+ end
87
+
88
+ objects
89
+ end
90
+
91
+ # Used by Object to fetch metadata if needed.
92
+ def head_object(object)
93
+ resp = nil
94
+ with_retries("HEAD '#{container}/#{object.name}'") do
95
+ resp = connection.head_object(container, object.name)
96
+ end
97
+ resp
98
+ end
99
+
100
+ # Delete non-SLO object(s) from the container.
101
+ #
102
+ # - Called by the Storage (with objects) and the Syncer (with names)
103
+ # - Deletes 10,000 objects per request.
104
+ # - Missing objects will be ignored.
105
+ def delete(objects_or_names)
106
+ names = Array(objects_or_names).dup
107
+ names.map!(&:name) if names.first.is_a?(Object)
108
+
109
+ until names.empty?
110
+ names_partial = names.slice!(0, 10_000)
111
+ with_retries("DELETE Multiple Objects") do
112
+ resp = connection.delete_multiple_objects(container, names_partial)
113
+ resp_status = resp.body["Response Status"]
114
+ raise Error, <<-EOS unless resp_status == "200 OK"
115
+ #{resp_status}
116
+ The server returned the following:
117
+ #{resp.body.inspect}
118
+ EOS
119
+ end
120
+ end
121
+ end
122
+
123
+ # Delete an SLO object(s) from the container.
124
+ #
125
+ # - Used only by the Storage. The Syncer cannot use SLOs.
126
+ # - Removes the SLO manifest object and all associated segments.
127
+ # - Missing segments will be ignored.
128
+ def delete_slo(objects)
129
+ Array(objects).each do |object|
130
+ with_retries("DELETE SLO Manifest '#{container}/#{object.name}'") do
131
+ resp = connection.delete_static_large_object(container, object.name)
132
+ resp_status = resp.body["Response Status"]
133
+ raise Error, <<-EOS unless resp_status == "200 OK"
134
+ #{resp_status}
135
+ The server returned the following:
136
+ #{resp.body.inspect}
137
+ EOS
138
+ end
139
+ end
140
+ end
141
+
142
+ private
143
+
144
+ def connection
145
+ @connection ||= Fog::Storage.new({
146
+ provider: "Rackspace",
147
+ rackspace_username: username,
148
+ rackspace_api_key: api_key,
149
+ rackspace_auth_url: auth_url,
150
+ rackspace_region: region,
151
+ rackspace_servicenet: servicenet
152
+ }.merge(fog_options || {}))
153
+ end
154
+
155
+ def create_containers
156
+ return if @containers_created
157
+
158
+ @containers_created = true
159
+
160
+ with_retries("Create Containers") do
161
+ connection.put_container(container)
162
+ connection.put_container(segments_container) if segments_container
163
+ end
164
+ end
165
+
166
+ def put_object(src, dest)
167
+ opts = headers.merge("ETag" => Digest::MD5.file(src).hexdigest)
168
+ with_retries("PUT '#{container}/#{dest}'") do
169
+ File.open(src, "r") do |file|
170
+ connection.put_object(container, dest, file, opts)
171
+ end
172
+ end
173
+ end
174
+
175
+ # Each segment is uploaded using chunked transfer encoding using
176
+ # SEGMENT_BUFFER, and each segment's MD5 is sent to verify the transfer.
177
+ # Each segment's MD5 and byte_size will also be verified when the
178
+ # SLO manifest object is uploaded.
179
+ def upload_segments(src, dest, segment_bytes, file_size)
180
+ total_segments = (file_size / segment_bytes.to_f).ceil
181
+ progress = (0.1..0.9).step(0.1).map { |n| (total_segments * n).floor }
182
+ Logger.info "\s\sUploading #{total_segments} SLO Segments..."
183
+
184
+ segments = []
185
+ File.open(src, "r") do |file|
186
+ segment_number = 0
187
+ until file.eof?
188
+ segment_number += 1
189
+ object = "#{dest}/#{segment_number.to_s.rjust(4, "0")}"
190
+ pos = file.pos
191
+ md5 = segment_md5(file, segment_bytes)
192
+ opts = headers.merge("ETag" => md5)
193
+
194
+ with_retries("PUT '#{segments_container}/#{object}'") do
195
+ file.seek(pos)
196
+ offset = 0
197
+ connection.put_object(segments_container, object, nil, opts) do
198
+ # block is called to stream data until it returns ''
199
+ data = ""
200
+ if offset <= segment_bytes - SEGMENT_BUFFER
201
+ data = file.read(SEGMENT_BUFFER).to_s # nil => ''
202
+ offset += data.size
203
+ end
204
+ data
205
+ end
206
+ end
207
+
208
+ segments << {
209
+ path: "#{segments_container}/#{object}",
210
+ etag: md5,
211
+ size_bytes: file.pos - pos
212
+ }
213
+
214
+ if (i = progress.rindex(segment_number))
215
+ Logger.info "\s\s...#{i + 1}0% Complete..."
216
+ end
217
+ end
218
+ end
219
+ segments
220
+ end
221
+
222
+ def segment_md5(file, segment_bytes)
223
+ md5 = Digest::MD5.new
224
+ offset = 0
225
+ while offset <= segment_bytes - SEGMENT_BUFFER
226
+ data = file.read(SEGMENT_BUFFER)
227
+ break unless data
228
+
229
+ offset += data.size
230
+ md5 << data
231
+ end
232
+ md5.hexdigest
233
+ end
234
+
235
+ # Each segment's ETag and byte_size will be verified once uploaded.
236
+ # Request will raise an exception if verification fails or segments
237
+ # are not found. However, each segment's ETag was verified when we
238
+ # uploaded the segments, so this should only retry failed requests.
239
+ def upload_manifest(dest, segments)
240
+ Logger.info "\s\sStoring SLO Manifest '#{container}/#{dest}'"
241
+
242
+ with_retries("PUT SLO Manifest '#{container}/#{dest}'") do
243
+ connection.put_static_obj_manifest(container, dest, segments, headers)
244
+ end
245
+ end
246
+
247
+ # If :days_to_keep was set, each object will be scheduled for deletion.
248
+ # This includes non-SLO objects, the SLO manifest and all segments.
249
+ def headers
250
+ headers = {}
251
+ headers["X-Delete-At"] = delete_at if delete_at
252
+ headers
253
+ end
254
+
255
+ def delete_at
256
+ return unless days_to_keep
257
+
258
+ @delete_at ||= (Time.now.utc + days_to_keep * 60**2 * 24).to_i
259
+ end
260
+
261
+ def adjusted_segment_bytes(segment_bytes, file_size)
262
+ return segment_bytes if file_size / segment_bytes.to_f <= 1000
263
+
264
+ mb = orig_mb = segment_bytes / 1024**2
265
+ mb += 1 until file_size / (1024**2 * mb).to_f <= 1000
266
+ Logger.warn Error.new(<<-EOS)
267
+ Segment Size Adjusted
268
+ Your original #segment_size of #{orig_mb} MiB has been adjusted
269
+ to #{mb} MiB in order to satisfy the limit of 1000 segments.
270
+ To enforce your chosen #segment_size, you should use the Splitter.
271
+ e.g. split_into_chunks_of #{mb * 1000} (#segment_size * 1000)
272
+ EOS
273
+ 1024**2 * mb
274
+ end
275
+
276
+ class Object
277
+ attr_reader :name, :hash
278
+
279
+ def initialize(cloud_io, data)
280
+ @cloud_io = cloud_io
281
+ @name = data["name"]
282
+ @hash = data["hash"]
283
+ end
284
+
285
+ def slo?
286
+ !!metadata["X-Static-Large-Object"]
287
+ end
288
+
289
+ def marked_for_deletion?
290
+ !!metadata["X-Delete-At"]
291
+ end
292
+
293
+ private
294
+
295
+ def metadata
296
+ @metadata ||= @cloud_io.head_object(self).headers
297
+ end
298
+ end
299
+ end
300
+ end
301
+ end
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "backup/cloud_io/base"
4
+ require "fog"
5
+ require "digest/md5"
6
+ require "base64"
7
+ require "stringio"
8
+
9
+ module Backup
10
+ module CloudIO
11
+ class S3 < Base
12
+ class Error < Backup::Error; end
13
+
14
+ MAX_FILE_SIZE = 1024**3 * 5 # 5 GiB
15
+ MAX_MULTIPART_SIZE = 1024**4 * 5 # 5 TiB
16
+
17
+ attr_reader :access_key_id, :secret_access_key, :use_iam_profile,
18
+ :region, :bucket, :chunk_size, :encryption, :storage_class,
19
+ :fog_options
20
+
21
+ def initialize(options = {})
22
+ super
23
+
24
+ @access_key_id = options[:access_key_id]
25
+ @secret_access_key = options[:secret_access_key]
26
+ @use_iam_profile = options[:use_iam_profile]
27
+ @region = options[:region]
28
+ @bucket = options[:bucket]
29
+ @chunk_size = options[:chunk_size]
30
+ @encryption = options[:encryption]
31
+ @storage_class = options[:storage_class]
32
+ @fog_options = options[:fog_options]
33
+ end
34
+
35
+ # The Syncer may call this method in multiple threads.
36
+ # However, #objects is always called prior to multithreading.
37
+ def upload(src, dest)
38
+ file_size = File.size(src)
39
+ chunk_bytes = chunk_size * 1024**2
40
+ if chunk_bytes > 0 && file_size > chunk_bytes
41
+ raise FileSizeError, <<-EOS if file_size > MAX_MULTIPART_SIZE
42
+ File Too Large
43
+ File: #{src}
44
+ Size: #{file_size}
45
+ Max Multipart Upload Size is #{MAX_MULTIPART_SIZE} (5 TiB)
46
+ EOS
47
+
48
+ chunk_bytes = adjusted_chunk_bytes(chunk_bytes, file_size)
49
+ upload_id = initiate_multipart(dest)
50
+ parts = upload_parts(src, dest, upload_id, chunk_bytes, file_size)
51
+ complete_multipart(dest, upload_id, parts)
52
+ else
53
+ raise FileSizeError, <<-EOS if file_size > MAX_FILE_SIZE
54
+ File Too Large
55
+ File: #{src}
56
+ Size: #{file_size}
57
+ Max File Size is #{MAX_FILE_SIZE} (5 GiB)
58
+ EOS
59
+
60
+ put_object(src, dest)
61
+ end
62
+ end
63
+
64
+ # Returns all objects in the bucket with the given prefix.
65
+ #
66
+ # - #get_bucket returns a max of 1000 objects per request.
67
+ # - Returns objects in alphabetical order.
68
+ # - If marker is given, only objects after the marker are in the response.
69
+ def objects(prefix)
70
+ objects = []
71
+ resp = nil
72
+ prefix = prefix.chomp("/")
73
+ opts = { "prefix" => prefix + "/" }
74
+
75
+ while resp.nil? || resp.body["IsTruncated"]
76
+ opts["marker"] = objects.last.key unless objects.empty?
77
+ with_retries("GET '#{bucket}/#{prefix}/*'") do
78
+ resp = connection.get_bucket(bucket, opts)
79
+ end
80
+ resp.body["Contents"].each do |obj_data|
81
+ objects << Object.new(self, obj_data)
82
+ end
83
+ end
84
+
85
+ objects
86
+ end
87
+
88
+ # Used by Object to fetch metadata if needed.
89
+ def head_object(object)
90
+ resp = nil
91
+ with_retries("HEAD '#{bucket}/#{object.key}'") do
92
+ resp = connection.head_object(bucket, object.key)
93
+ end
94
+ resp
95
+ end
96
+
97
+ # Delete object(s) from the bucket.
98
+ #
99
+ # - Called by the Storage (with objects) and the Syncer (with keys)
100
+ # - Deletes 1000 objects per request.
101
+ # - Missing objects will be ignored.
102
+ def delete(objects_or_keys)
103
+ keys = Array(objects_or_keys).dup
104
+ keys.map!(&:key) if keys.first.is_a?(Object)
105
+
106
+ opts = { quiet: true } # only report Errors in DeleteResult
107
+ until keys.empty?
108
+ keys_partial = keys.slice!(0, 1000)
109
+ with_retries("DELETE Multiple Objects") do
110
+ resp = connection.delete_multiple_objects(bucket, keys_partial, opts.dup)
111
+ unless resp.body["DeleteResult"].empty?
112
+ errors = resp.body["DeleteResult"].map do |result|
113
+ error = result["Error"]
114
+ "Failed to delete: #{error["Key"]}\n" \
115
+ "Reason: #{error["Code"]}: #{error["Message"]}"
116
+ end.join("\n")
117
+ raise Error, "The server returned the following:\n#{errors}"
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ def connection
126
+ @connection ||=
127
+ begin
128
+ opts = { provider: "AWS", region: region }
129
+ if use_iam_profile
130
+ opts[:use_iam_profile] = true
131
+ else
132
+ opts[:aws_access_key_id] = access_key_id
133
+ opts[:aws_secret_access_key] = secret_access_key
134
+ end
135
+ opts.merge!(fog_options || {})
136
+ conn = Fog::Storage.new(opts)
137
+ conn.sync_clock
138
+ conn
139
+ end
140
+ end
141
+
142
+ def put_object(src, dest)
143
+ md5 = Base64.encode64(Digest::MD5.file(src).digest).chomp
144
+ options = headers.merge("Content-MD5" => md5)
145
+ with_retries("PUT '#{bucket}/#{dest}'") do
146
+ File.open(src, "r") do |file|
147
+ connection.put_object(bucket, dest, file, options)
148
+ end
149
+ end
150
+ end
151
+
152
+ def initiate_multipart(dest)
153
+ Logger.info "\s\sInitiate Multipart '#{bucket}/#{dest}'"
154
+
155
+ resp = nil
156
+ with_retries("POST '#{bucket}/#{dest}' (Initiate)") do
157
+ resp = connection.initiate_multipart_upload(bucket, dest, headers)
158
+ end
159
+ resp.body["UploadId"]
160
+ end
161
+
162
+ # Each part's MD5 is sent to verify the transfer.
163
+ # AWS will concatenate all parts into a single object
164
+ # once the multipart upload is completed.
165
+ def upload_parts(src, dest, upload_id, chunk_bytes, file_size)
166
+ total_parts = (file_size / chunk_bytes.to_f).ceil
167
+ progress = (0.1..0.9).step(0.1).map { |n| (total_parts * n).floor }
168
+ Logger.info "\s\sUploading #{total_parts} Parts..."
169
+
170
+ parts = []
171
+ File.open(src, "r") do |file|
172
+ part_number = 0
173
+ while (data = file.read(chunk_bytes))
174
+ part_number += 1
175
+ md5 = Base64.encode64(Digest::MD5.digest(data)).chomp
176
+
177
+ with_retries("PUT '#{bucket}/#{dest}' Part ##{part_number}") do
178
+ resp = connection.upload_part(
179
+ bucket, dest, upload_id, part_number, StringIO.new(data),
180
+ "Content-MD5" => md5
181
+ )
182
+ parts << resp.headers["ETag"]
183
+ end
184
+
185
+ if (i = progress.rindex(part_number))
186
+ Logger.info "\s\s...#{i + 1}0% Complete..."
187
+ end
188
+ end
189
+ end
190
+ parts
191
+ end
192
+
193
+ def complete_multipart(dest, upload_id, parts)
194
+ Logger.info "\s\sComplete Multipart '#{bucket}/#{dest}'"
195
+
196
+ with_retries("POST '#{bucket}/#{dest}' (Complete)") do
197
+ resp = connection.complete_multipart_upload(bucket, dest,
198
+ upload_id, parts)
199
+ raise Error, <<-EOS if resp.body["Code"]
200
+ The server returned the following error:
201
+ #{resp.body["Code"]}: #{resp.body["Message"]}
202
+ EOS
203
+ end
204
+ end
205
+
206
+ def headers
207
+ headers = {}
208
+
209
+ enc = encryption.to_s.upcase
210
+ headers["x-amz-server-side-encryption"] = enc unless enc.empty?
211
+
212
+ sc = storage_class.to_s.upcase
213
+ headers["x-amz-storage-class"] = sc unless sc.empty? || sc == "STANDARD"
214
+
215
+ headers
216
+ end
217
+
218
+ def adjusted_chunk_bytes(chunk_bytes, file_size)
219
+ return chunk_bytes if file_size / chunk_bytes.to_f <= 10_000
220
+
221
+ mb = orig_mb = chunk_bytes / 1024**2
222
+ mb += 1 until file_size / (1024**2 * mb).to_f <= 10_000
223
+ Logger.warn Error.new(<<-EOS)
224
+ Chunk Size Adjusted
225
+ Your original #chunk_size of #{orig_mb} MiB has been adjusted
226
+ to #{mb} MiB in order to satisfy the limit of 10,000 chunks.
227
+ To enforce your chosen #chunk_size, you should use the Splitter.
228
+ e.g. split_into_chunks_of #{mb * 10_000} (#chunk_size * 10_000)
229
+ EOS
230
+ 1024**2 * mb
231
+ end
232
+
233
+ class Object
234
+ attr_reader :key, :etag, :storage_class
235
+
236
+ def initialize(cloud_io, data)
237
+ @cloud_io = cloud_io
238
+ @key = data["Key"]
239
+ @etag = data["ETag"]
240
+ @storage_class = data["StorageClass"]
241
+ end
242
+
243
+ # currently 'AES256' or nil
244
+ def encryption
245
+ metadata["x-amz-server-side-encryption"]
246
+ end
247
+
248
+ private
249
+
250
+ def metadata
251
+ @metadata ||= @cloud_io.head_object(self).headers
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Backup
4
+ module Compressor
5
+ class Base
6
+ include Utilities::Helpers
7
+ include Config::Helpers
8
+
9
+ ##
10
+ # Yields to the block the compressor command and filename extension.
11
+ def compress_with
12
+ log!
13
+ yield @cmd, @ext
14
+ end
15
+
16
+ private
17
+
18
+ ##
19
+ # Return the compressor name, with Backup namespace removed
20
+ def compressor_name
21
+ self.class.to_s.sub("Backup::", "")
22
+ end
23
+
24
+ ##
25
+ # Logs a message to the console and log file to inform
26
+ # the client that Backup is using the compressor
27
+ def log!
28
+ Logger.info "Using #{compressor_name} for compression.\n" \
29
+ " Command: '#{@cmd}'\n" \
30
+ " Ext: '#{@ext}'"
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Backup
4
+ module Compressor
5
+ class Bzip2 < Base
6
+ ##
7
+ # Specify the level of compression to use.
8
+ #
9
+ # Values should be a single digit from 1 to 9.
10
+ # Note that setting the level to either extreme may or may not
11
+ # give the desired result. Be sure to check the documentation
12
+ # for the compressor being used.
13
+ #
14
+ # The default `level` is 9.
15
+ attr_accessor :level
16
+
17
+ ##
18
+ # Creates a new instance of Backup::Compressor::Bzip2
19
+ def initialize(&block)
20
+ load_defaults!
21
+
22
+ @level ||= false
23
+
24
+ instance_eval(&block) if block_given?
25
+
26
+ @cmd = "#{utility(:bzip2)}#{options}"
27
+ @ext = ".bz2"
28
+ end
29
+
30
+ private
31
+
32
+ def options
33
+ " -#{@level}" if @level
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Backup
4
+ module Compressor
5
+ class Custom < Base
6
+ ##
7
+ # Specify the system command to invoke a compressor,
8
+ # including any command-line arguments.
9
+ # e.g. @compressor.command = 'pbzip2 -p2 -4'
10
+ #
11
+ # The data to be compressed will be piped to the command's STDIN,
12
+ # and it should write the compressed data to STDOUT.
13
+ # i.e. `cat file.tar | %command% > file.tar.%extension%`
14
+ attr_accessor :command
15
+
16
+ ##
17
+ # File extension to append to the compressed file's filename.
18
+ # e.g. @compressor.extension = '.bz2'
19
+ attr_accessor :extension
20
+
21
+ ##
22
+ # Initializes a new custom compressor.
23
+ def initialize(&block)
24
+ load_defaults!
25
+
26
+ instance_eval(&block) if block_given?
27
+
28
+ @cmd = set_cmd
29
+ @ext = set_ext
30
+ end
31
+
32
+ private
33
+
34
+ ##
35
+ # Return the command line using the full path.
36
+ # Ensures the command exists and is executable.
37
+ def set_cmd
38
+ parts = @command.to_s.split(" ")
39
+ parts[0] = utility(parts[0])
40
+ parts.join(" ")
41
+ end
42
+
43
+ ##
44
+ # Return the extension given without whitespace.
45
+ # If extension was not set, return an empty string
46
+ def set_ext
47
+ @extension.to_s.strip
48
+ end
49
+ end
50
+ end
51
+ end