fakes3-docker 0.2.4

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.
@@ -0,0 +1,21 @@
1
+ module FakeS3
2
+ class RateLimitableFile < File
3
+ @@rate_limit = nil
4
+ # Specify a rate limit in bytes per second
5
+ def self.rate_limit
6
+ @@rate_limit
7
+ end
8
+
9
+ def self.rate_limit=(rate_limit)
10
+ @@rate_limit = rate_limit
11
+ end
12
+
13
+ def read(args)
14
+ if @@rate_limit
15
+ time_to_sleep = args / @@rate_limit
16
+ sleep(time_to_sleep)
17
+ end
18
+ return super(args)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,19 @@
1
+ module FakeS3
2
+ class S3Object
3
+ include Comparable
4
+ attr_accessor :name,:size,:creation_date,:modified_date,:md5,:io,:content_type,:custom_metadata
5
+
6
+ def hash
7
+ @name.hash
8
+ end
9
+
10
+ def eql?(object)
11
+ object.is_a?(self.class) ? (@name == object.name) : false
12
+ end
13
+
14
+ # Sort by the object's name
15
+ def <=>(object)
16
+ object.is_a?(self.class) ? (@name <=> object.name) : nil
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,546 @@
1
+ require 'time'
2
+ require 'webrick'
3
+ require 'webrick/https'
4
+ require 'openssl'
5
+ require 'securerandom'
6
+ require 'cgi'
7
+ require 'fakes3/file_store'
8
+ require 'fakes3/xml_adapter'
9
+ require 'fakes3/bucket_query'
10
+ require 'fakes3/unsupported_operation'
11
+ require 'fakes3/errors'
12
+ require 'ipaddr'
13
+ require 'uri'
14
+
15
+ module FakeS3
16
+ class Request
17
+ CREATE_BUCKET = "CREATE_BUCKET"
18
+ LIST_BUCKETS = "LIST_BUCKETS"
19
+ LS_BUCKET = "LS_BUCKET"
20
+ HEAD = "HEAD"
21
+ STORE = "STORE"
22
+ COPY = "COPY"
23
+ GET = "GET"
24
+ GET_ACL = "GET_ACL"
25
+ SET_ACL = "SET_ACL"
26
+ MOVE = "MOVE"
27
+ DELETE_OBJECT = "DELETE_OBJECT"
28
+ DELETE_BUCKET = "DELETE_BUCKET"
29
+
30
+ attr_accessor :bucket,:object,:type,:src_bucket,
31
+ :src_object,:method,:webrick_request,
32
+ :path,:is_path_style,:query,:http_verb
33
+
34
+ def inspect
35
+ puts "-----Inspect FakeS3 Request"
36
+ puts "Type: #{@type}"
37
+ puts "Is Path Style: #{@is_path_style}"
38
+ puts "Request Method: #{@method}"
39
+ puts "Bucket: #{@bucket}"
40
+ puts "Object: #{@object}"
41
+ puts "Src Bucket: #{@src_bucket}"
42
+ puts "Src Object: #{@src_object}"
43
+ puts "Query: #{@query}"
44
+ puts "-----Done"
45
+ end
46
+ end
47
+
48
+ class Servlet < WEBrick::HTTPServlet::AbstractServlet
49
+ def initialize(server,store,hostname)
50
+ super(server)
51
+ @store = store
52
+ @hostname = hostname
53
+ @port = server.config[:Port]
54
+ @root_hostnames = [hostname,'localhost','s3.amazonaws.com','s3.localhost']
55
+ if ENV.key?('DOCKER_HOST')
56
+ @root_hostnames << URI(ENV['DOCKER_HOST']).host
57
+ end
58
+ end
59
+
60
+ def validate_request(request)
61
+ req = request.webrick_request
62
+ return if req.nil?
63
+ return if not req.header.has_key?('expect')
64
+ req.continue if req.header['expect'].first=='100-continue'
65
+ end
66
+
67
+ def do_GET(request, response)
68
+ s_req = normalize_request(request)
69
+
70
+ case s_req.type
71
+ when 'LIST_BUCKETS'
72
+ response.status = 200
73
+ response['Content-Type'] = 'application/xml'
74
+ buckets = @store.buckets
75
+ response.body = XmlAdapter.buckets(buckets)
76
+ when 'LS_BUCKET'
77
+ bucket_obj = @store.get_bucket(s_req.bucket)
78
+ if bucket_obj
79
+ response.status = 200
80
+ response['Content-Type'] = "application/xml"
81
+ query = {
82
+ :marker => s_req.query["marker"] ? s_req.query["marker"].to_s : nil,
83
+ :prefix => s_req.query["prefix"] ? s_req.query["prefix"].to_s : nil,
84
+ :max_keys => s_req.query["max_keys"] ? s_req.query["max_keys"].to_s : nil,
85
+ :delimiter => s_req.query["delimiter"] ? s_req.query["delimiter"].to_s : nil
86
+ }
87
+ bq = bucket_obj.query_for_range(query)
88
+ response.body = XmlAdapter.bucket_query(bq)
89
+ else
90
+ response.status = 404
91
+ response.body = XmlAdapter.error_no_such_bucket(s_req.bucket)
92
+ response['Content-Type'] = "application/xml"
93
+ end
94
+ when 'GET_ACL'
95
+ response.status = 200
96
+ response.body = XmlAdapter.acl()
97
+ response['Content-Type'] = 'application/xml'
98
+ when 'GET'
99
+ real_obj = @store.get_object(s_req.bucket,s_req.object,request)
100
+ if !real_obj
101
+ response.status = 404
102
+ response.body = XmlAdapter.error_no_such_key(s_req.object)
103
+ response['Content-Type'] = "application/xml"
104
+ return
105
+ end
106
+
107
+ if_none_match = request["If-None-Match"]
108
+ if if_none_match == "\"#{real_obj.md5}\"" or if_none_match == "*"
109
+ response.status = 304
110
+ return
111
+ end
112
+
113
+ if_modified_since = request["If-Modified-Since"]
114
+ if if_modified_since
115
+ time = Time.httpdate(if_modified_since)
116
+ if time >= Time.iso8601(real_obj.modified_date)
117
+ response.status = 304
118
+ return
119
+ end
120
+ end
121
+
122
+ response.status = 200
123
+ response['Content-Type'] = real_obj.content_type
124
+ stat = File::Stat.new(real_obj.io.path)
125
+
126
+ response['Last-Modified'] = Time.iso8601(real_obj.modified_date).httpdate()
127
+ response.header['ETag'] = "\"#{real_obj.md5}\""
128
+ response['Accept-Ranges'] = "bytes"
129
+ response['Last-Ranges'] = "bytes"
130
+ response['Access-Control-Allow-Origin'] = '*'
131
+
132
+ real_obj.custom_metadata.each do |header, value|
133
+ response.header['x-amz-meta-' + header] = value
134
+ end
135
+
136
+ content_length = stat.size
137
+
138
+ # Added Range Query support
139
+ if range = request.header["range"].first
140
+ response.status = 206
141
+ if range =~ /bytes=(\d*)-(\d*)/
142
+ start = $1.to_i
143
+ finish = $2.to_i
144
+ finish_str = ""
145
+ if finish == 0
146
+ finish = content_length - 1
147
+ finish_str = "#{finish}"
148
+ else
149
+ finish_str = finish.to_s
150
+ end
151
+
152
+ bytes_to_read = finish - start + 1
153
+ response['Content-Range'] = "bytes #{start}-#{finish_str}/#{content_length}"
154
+ real_obj.io.pos = start
155
+ response.body = real_obj.io.read(bytes_to_read)
156
+ return
157
+ end
158
+ end
159
+ response['Content-Length'] = File::Stat.new(real_obj.io.path).size
160
+ if s_req.http_verb == 'HEAD'
161
+ response.body = ""
162
+ else
163
+ response.body = real_obj.io
164
+ end
165
+ end
166
+ end
167
+
168
+ def do_PUT(request,response)
169
+ s_req = normalize_request(request)
170
+ query = CGI::parse(request.request_uri.query || "")
171
+
172
+ return do_multipartPUT(request, response) if query['uploadId'].first
173
+
174
+ response.status = 200
175
+ response.body = ""
176
+ response['Content-Type'] = "text/xml"
177
+ response['Access-Control-Allow-Origin'] = '*'
178
+
179
+ case s_req.type
180
+ when Request::COPY
181
+ object = @store.copy_object(s_req.src_bucket,s_req.src_object,s_req.bucket,s_req.object,request)
182
+ response.body = XmlAdapter.copy_object_result(object)
183
+ when Request::STORE
184
+ bucket_obj = @store.get_bucket(s_req.bucket)
185
+ if !bucket_obj
186
+ # Lazily create a bucket. TODO fix this to return the proper error
187
+ bucket_obj = @store.create_bucket(s_req.bucket)
188
+ end
189
+
190
+ real_obj = @store.store_object(bucket_obj,s_req.object,s_req.webrick_request)
191
+ response.header['ETag'] = "\"#{real_obj.md5}\""
192
+ when Request::CREATE_BUCKET
193
+ @store.create_bucket(s_req.bucket)
194
+ end
195
+ end
196
+
197
+ def do_multipartPUT(request, response)
198
+ s_req = normalize_request(request)
199
+ query = CGI::parse(request.request_uri.query)
200
+
201
+ part_number = query['partNumber'].first
202
+ upload_id = query['uploadId'].first
203
+ part_name = "#{upload_id}_#{s_req.object}_part#{part_number}"
204
+
205
+ # store the part
206
+ if s_req.type == Request::COPY
207
+ real_obj = @store.copy_object(
208
+ s_req.src_bucket, s_req.src_object,
209
+ s_req.bucket , part_name,
210
+ request
211
+ )
212
+
213
+ response['Content-Type'] = "text/xml"
214
+ response.body = XmlAdapter.copy_object_result real_obj
215
+ else
216
+ bucket_obj = @store.get_bucket(s_req.bucket)
217
+ if !bucket_obj
218
+ bucket_obj = @store.create_bucket(s_req.bucket)
219
+ end
220
+ real_obj = @store.store_object(
221
+ bucket_obj, part_name,
222
+ request
223
+ )
224
+
225
+ response.body = ""
226
+ response.header['ETag'] = "\"#{real_obj.md5}\""
227
+ end
228
+
229
+ response['Access-Control-Allow-Origin'] = '*'
230
+ response['Access-Control-Allow-Headers'] = 'Authorization, Content-Length'
231
+ response['Access-Control-Expose-Headers'] = 'ETag'
232
+
233
+ response.status = 200
234
+ end
235
+
236
+ def do_POST(request,response)
237
+ s_req = normalize_request(request)
238
+ key = request.query['key']
239
+ query = CGI::parse(request.request_uri.query || "")
240
+
241
+ if query.has_key?('uploads')
242
+ upload_id = SecureRandom.hex
243
+
244
+ response.body = <<-eos.strip
245
+ <?xml version="1.0" encoding="UTF-8"?>
246
+ <InitiateMultipartUploadResult>
247
+ <Bucket>#{ s_req.bucket }</Bucket>
248
+ <Key>#{ key }</Key>
249
+ <UploadId>#{ upload_id }</UploadId>
250
+ </InitiateMultipartUploadResult>
251
+ eos
252
+ elsif query.has_key?('uploadId')
253
+ upload_id = query['uploadId'].first
254
+ bucket_obj = @store.get_bucket(s_req.bucket)
255
+ real_obj = @store.combine_object_parts(
256
+ bucket_obj,
257
+ upload_id,
258
+ s_req.object,
259
+ parse_complete_multipart_upload(request),
260
+ request
261
+ )
262
+
263
+ response.body = XmlAdapter.complete_multipart_result real_obj
264
+ elsif request.content_type =~ /^multipart\/form-data; boundary=(.+)/
265
+ key=request.query['key']
266
+
267
+ success_action_redirect = request.query['success_action_redirect']
268
+ success_action_status = request.query['success_action_status']
269
+
270
+ filename = 'default'
271
+ filename = $1 if request.body =~ /filename="(.*)"/
272
+ key = key.gsub('${filename}', filename)
273
+
274
+ bucket_obj = @store.get_bucket(s_req.bucket) || @store.create_bucket(s_req.bucket)
275
+ real_obj = @store.store_object(bucket_obj, key, s_req.webrick_request)
276
+
277
+ response['Etag'] = "\"#{real_obj.md5}\""
278
+
279
+ if success_action_redirect
280
+ response.status = 307
281
+ response.body = ""
282
+ response['Location'] = success_action_redirect
283
+ else
284
+ response.status = success_action_status || 204
285
+ if response.status == "201"
286
+ response.body = <<-eos.strip
287
+ <?xml version="1.0" encoding="UTF-8"?>
288
+ <PostResponse>
289
+ <Location>http://#{s_req.bucket}.localhost:#{@port}/#{key}</Location>
290
+ <Bucket>#{s_req.bucket}</Bucket>
291
+ <Key>#{key}</Key>
292
+ <ETag>#{response['Etag']}</ETag>
293
+ </PostResponse>
294
+ eos
295
+ end
296
+ end
297
+ else
298
+ raise WEBrick::HTTPStatus::BadRequest
299
+ end
300
+
301
+ response['Content-Type'] = 'text/xml'
302
+ response['Access-Control-Allow-Origin'] = '*'
303
+ response['Access-Control-Allow-Headers'] = 'Authorization, Content-Length'
304
+ response['Access-Control-Expose-Headers'] = 'ETag'
305
+ end
306
+
307
+ def do_DELETE(request,response)
308
+ s_req = normalize_request(request)
309
+
310
+ case s_req.type
311
+ when Request::DELETE_OBJECT
312
+ bucket_obj = @store.get_bucket(s_req.bucket)
313
+ @store.delete_object(bucket_obj,s_req.object,s_req.webrick_request)
314
+ when Request::DELETE_BUCKET
315
+ @store.delete_bucket(s_req.bucket)
316
+ end
317
+
318
+ response.status = 204
319
+ response.body = ""
320
+ end
321
+
322
+ def do_OPTIONS(request, response)
323
+ super
324
+
325
+ response['Access-Control-Allow-Origin'] = '*'
326
+ response['Access-Control-Allow-Methods'] = 'PUT, POST, HEAD, GET, OPTIONS'
327
+ response['Access-Control-Allow-Headers'] = 'Accept, Content-Type, Authorization, Content-Length, ETag'
328
+ response['Access-Control-Expose-Headers'] = 'ETag'
329
+ end
330
+
331
+ private
332
+
333
+ def normalize_delete(webrick_req,s_req)
334
+ path = webrick_req.path
335
+ path_len = path.size
336
+ query = webrick_req.query
337
+ if path == "/" and s_req.is_path_style
338
+ # Probably do a 404 here
339
+ else
340
+ if s_req.is_path_style
341
+ elems = path[1,path_len].split("/")
342
+ s_req.bucket = elems[0]
343
+ else
344
+ elems = path.split("/")
345
+ end
346
+
347
+ if elems.size == 0
348
+ raise UnsupportedOperation
349
+ elsif elems.size == 1
350
+ s_req.type = Request::DELETE_BUCKET
351
+ s_req.query = query
352
+ else
353
+ s_req.type = Request::DELETE_OBJECT
354
+ object = elems[1,elems.size].join('/')
355
+ s_req.object = object
356
+ end
357
+ end
358
+ end
359
+
360
+ def normalize_get(webrick_req,s_req)
361
+ path = webrick_req.path
362
+ path_len = path.size
363
+ query = webrick_req.query
364
+ if path == "/" and s_req.is_path_style
365
+ s_req.type = Request::LIST_BUCKETS
366
+ else
367
+ if s_req.is_path_style
368
+ elems = path[1,path_len].split("/")
369
+ s_req.bucket = elems[0]
370
+ else
371
+ elems = path.split("/")
372
+ end
373
+
374
+ if elems.size < 2
375
+ s_req.type = Request::LS_BUCKET
376
+ s_req.query = query
377
+ else
378
+ if query["acl"] == ""
379
+ s_req.type = Request::GET_ACL
380
+ else
381
+ s_req.type = Request::GET
382
+ end
383
+ object = elems[1,elems.size].join('/')
384
+ s_req.object = object
385
+ end
386
+ end
387
+ end
388
+
389
+ def normalize_put(webrick_req,s_req)
390
+ path = webrick_req.path
391
+ path_len = path.size
392
+ if path == "/"
393
+ if s_req.bucket
394
+ s_req.type = Request::CREATE_BUCKET
395
+ end
396
+ else
397
+ if s_req.is_path_style
398
+ elems = path[1,path_len].split("/")
399
+ s_req.bucket = elems[0]
400
+ if elems.size == 1
401
+ s_req.type = Request::CREATE_BUCKET
402
+ else
403
+ if webrick_req.request_line =~ /\?acl/
404
+ s_req.type = Request::SET_ACL
405
+ else
406
+ s_req.type = Request::STORE
407
+ end
408
+ s_req.object = elems[1,elems.size].join('/')
409
+ end
410
+ else
411
+ if webrick_req.request_line =~ /\?acl/
412
+ s_req.type = Request::SET_ACL
413
+ else
414
+ s_req.type = Request::STORE
415
+ end
416
+ s_req.object = webrick_req.path[1..-1]
417
+ end
418
+ end
419
+
420
+ # TODO: also parse the x-amz-copy-source-range:bytes=first-last header
421
+ # for multipart copy
422
+ copy_source = webrick_req.header["x-amz-copy-source"]
423
+ if copy_source and copy_source.size == 1
424
+ src_elems = copy_source.first.split("/")
425
+ root_offset = src_elems[0] == "" ? 1 : 0
426
+ s_req.src_bucket = src_elems[root_offset]
427
+ s_req.src_object = src_elems[1 + root_offset,src_elems.size].join("/")
428
+ s_req.type = Request::COPY
429
+ end
430
+
431
+ s_req.webrick_request = webrick_req
432
+ end
433
+
434
+ def normalize_post(webrick_req,s_req)
435
+ path = webrick_req.path
436
+ path_len = path.size
437
+
438
+ s_req.path = webrick_req.query['key']
439
+
440
+ s_req.webrick_request = webrick_req
441
+
442
+ if s_req.is_path_style
443
+ elems = path[1,path_len].split("/")
444
+ s_req.bucket = elems[0]
445
+ s_req.object = elems[1..-1].join('/') if elems.size >= 2
446
+ else
447
+ s_req.object = path[1..-1]
448
+ end
449
+ end
450
+
451
+ # This method takes a webrick request and generates a normalized FakeS3 request
452
+ def normalize_request(webrick_req)
453
+ host_header= webrick_req["Host"]
454
+ host = host_header.split(':')[0]
455
+
456
+ s_req = Request.new
457
+ s_req.path = webrick_req.path
458
+ s_req.is_path_style = true
459
+
460
+ if !@root_hostnames.include?(host) && !(IPAddr.new(host) rescue nil)
461
+ s_req.bucket = host.split(".")[0]
462
+ s_req.is_path_style = false
463
+ end
464
+
465
+ s_req.http_verb = webrick_req.request_method
466
+
467
+ case webrick_req.request_method
468
+ when 'PUT'
469
+ normalize_put(webrick_req,s_req)
470
+ when 'GET','HEAD'
471
+ normalize_get(webrick_req,s_req)
472
+ when 'DELETE'
473
+ normalize_delete(webrick_req,s_req)
474
+ when 'POST'
475
+ normalize_post(webrick_req,s_req)
476
+ else
477
+ raise "Unknown Request"
478
+ end
479
+
480
+ validate_request(s_req)
481
+
482
+ return s_req
483
+ end
484
+
485
+ def parse_complete_multipart_upload request
486
+ parts_xml = ""
487
+ request.body { |chunk| parts_xml << chunk }
488
+
489
+ # TODO: I suck at parsing xml
490
+ parts_xml = parts_xml.scan /\<Part\>.*?<\/Part\>/m
491
+
492
+ parts_xml.collect do |xml|
493
+ {
494
+ number: xml[/\<PartNumber\>(\d+)\<\/PartNumber\>/, 1].to_i,
495
+ etag: xml[/\<ETag\>\"(.+)\"\<\/ETag\>/, 1]
496
+ }
497
+ end
498
+ end
499
+
500
+ def dump_request(request)
501
+ puts "----------Dump Request-------------"
502
+ puts request.request_method
503
+ puts request.path
504
+ request.each do |k,v|
505
+ puts "#{k}:#{v}"
506
+ end
507
+ puts "----------End Dump -------------"
508
+ end
509
+ end
510
+
511
+
512
+ class Server
513
+ def initialize(address,port,store,hostname,ssl_cert_path,ssl_key_path)
514
+ @address = address
515
+ @port = port
516
+ @store = store
517
+ @hostname = hostname
518
+ @ssl_cert_path = ssl_cert_path
519
+ @ssl_key_path = ssl_key_path
520
+ webrick_config = {
521
+ :BindAddress => @address,
522
+ :Port => @port
523
+ }
524
+ if !@ssl_cert_path.to_s.empty?
525
+ webrick_config.merge!(
526
+ {
527
+ :SSLEnable => true,
528
+ :SSLCertificate => OpenSSL::X509::Certificate.new(File.read(@ssl_cert_path)),
529
+ :SSLPrivateKey => OpenSSL::PKey::RSA.new(File.read(@ssl_key_path))
530
+ }
531
+ )
532
+ end
533
+ @server = WEBrick::HTTPServer.new(webrick_config)
534
+ end
535
+
536
+ def serve
537
+ @server.mount "/", Servlet, @store,@hostname
538
+ trap "INT" do @server.shutdown end
539
+ @server.start
540
+ end
541
+
542
+ def shutdown
543
+ @server.shutdown
544
+ end
545
+ end
546
+ end