configure-s3-website 1.2.0 → 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,16 +1,18 @@
1
1
  require 'rspec'
2
2
 
3
- Given /^my config file is in "(.*?)"$/ do |config_file_path|
4
- @config_file_path = config_file_path
5
- end
6
-
7
- When /^I run the configure-s3-website command$/ do
3
+ When /^I run the configure-s3-website command with parameters$/ do |table|
4
+ options, optparse = ConfigureS3Website::CLI.optparse_and_options
5
+ optparse.parse! args_array_from_cucumber_table(table)
6
+ @reset = create_reset_config_file_function options[:config_source].description
8
7
  @console_output = capture_stdout {
9
- config_source = ConfigureS3Website::FileConfigSource.new(@config_file_path)
10
- ConfigureS3Website::S3Client.configure_website(config_source)
8
+ ConfigureS3Website::Runner.run(options, stub_stdin)
11
9
  }
12
10
  end
13
11
 
12
+ Given /^I answer 'yes' to 'do you want to use CloudFront'$/ do
13
+ @first_stdin_answer = 'y'
14
+ end
15
+
14
16
  Then /^the output should be$/ do |expected_console_output|
15
17
  @console_output.should eq(expected_console_output)
16
18
  end
@@ -19,6 +21,41 @@ Then /^the output should include$/ do |expected_console_output|
19
21
  @console_output.should include(expected_console_output)
20
22
  end
21
23
 
24
+ def args_array_from_cucumber_table(table)
25
+ args = []
26
+ table.hashes.map do |entry|
27
+ { entry[:option] => entry[:value] }
28
+ end.each do |opt|
29
+ args << opt.keys.first
30
+ args << opt.values.first if opt.values.first
31
+ end
32
+ args
33
+ end
34
+
35
+ def stub_stdin
36
+ stdin = stub('std_in')
37
+ stdin.stub(:gets).and_return {
38
+ first_stdin_answer
39
+ }
40
+ stdin
41
+ end
42
+
43
+ # A function for bringing back the original config file
44
+ # (in case we modified it during the test)
45
+ def create_reset_config_file_function(yaml_file_path)
46
+ original_contents = File.open(yaml_file_path, 'r').read
47
+ -> {
48
+ File.open(yaml_file_path, 'w') { |yaml_file|
49
+ yaml_file.puts(original_contents)
50
+ }
51
+ }
52
+ end
53
+
54
+ # The first prompt asks "do you want to create a CloudFront distro"
55
+ def first_stdin_answer
56
+ @first_stdin_answer || 'n'
57
+ end
58
+
22
59
  module Kernel
23
60
  require 'stringio'
24
61
 
@@ -16,3 +16,7 @@ After do
16
16
  ENV['PATH'] = @__aruba_original_paths.join(File::PATH_SEPARATOR)
17
17
  end
18
18
  # End of following from 'aruba/cucumber'
19
+
20
+ After do
21
+ @reset.call if @reset
22
+ end
@@ -0,0 +1,4 @@
1
+ s3_id: foo
2
+ s3_secret: foo
3
+ s3_bucket: name-of-an-existing-bucket
4
+ cloudfront_distribution_id: EEFF123
@@ -0,0 +1,3 @@
1
+ s3_id: foo
2
+ s3_secret: foo
3
+ s3_bucket: website-via-cf
@@ -10,4 +10,5 @@ VCR.cucumber_tags do |t|
10
10
  t.tag '@bucket-does-not-exist-in-tokyo'
11
11
  t.tag '@bucket-exists'
12
12
  t.tag '@redirects'
13
+ t.tag '@create-cf-dist'
13
14
  end
@@ -1,4 +1,9 @@
1
1
  require 'configure-s3-website/version'
2
2
  require 'configure-s3-website/s3_client'
3
+ require 'configure-s3-website/cloudfront_client'
4
+ require 'configure-s3-website/xml_helper'
5
+ require 'configure-s3-website/http_helper'
6
+ require 'configure-s3-website/runner'
7
+ require 'configure-s3-website/cli'
3
8
  require 'configure-s3-website/config_source/config_source'
4
9
  require 'configure-s3-website/config_source/file_config_source'
@@ -0,0 +1,35 @@
1
+ module ConfigureS3Website
2
+ class CLI
3
+ def self.optparse_and_options
4
+ options = {}
5
+ optparse = OptionParser.new do |opts|
6
+ opts.banner = banner
7
+ opts.on('-f', '--config-file FILE',
8
+ 'Pick credentials and the S3 bucket name from a config file') do
9
+ |yaml_file_path|
10
+ options[:config_source] =
11
+ ConfigureS3Website::FileConfigSource.new yaml_file_path
12
+ end
13
+ opts.on('-v', '--verbose', 'Print more stuff') do
14
+ options[:verbose] = true
15
+ end
16
+ opts.on('--help', 'Display this screen') do
17
+ puts opts
18
+ exit
19
+ end
20
+ end
21
+ [options, optparse]
22
+ end
23
+
24
+ private
25
+
26
+ def self.banner
27
+ %|Usage: #{File.basename(__FILE__)} arguments
28
+
29
+ Configure your S3 bucket to function as a web site
30
+
31
+ Arguments:
32
+ |
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,117 @@
1
+ require "rexml/document"
2
+ require "rexml/xpath"
3
+
4
+ module ConfigureS3Website
5
+ class CloudFrontClient
6
+ def self.create_distribution_if_user_agrees(options, standard_input)
7
+ puts 'Do you want to deliver your website via CloudFront, the CDN of Amazon? [y/N]'
8
+ case standard_input.gets.chomp
9
+ when /(y|Y)/ then do_create_distribution options
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def self.do_create_distribution(options)
16
+ config_source = options[:config_source]
17
+ response = HttpHelper.call_cloudfront_api(
18
+ path = '/2012-07-01/distribution',
19
+ method = Net::HTTP::Post,
20
+ body = (distribution_config_xml config_source),
21
+ config_source = config_source
22
+ )
23
+ response_xml = REXML::Document.new(response.body)
24
+ dist_id = REXML::XPath.first(response_xml, '/Distribution/Id').get_text
25
+ print_report_on_new_dist response_xml, dist_id, options
26
+ config_source.cloudfront_distribution_id = dist_id.to_s
27
+ puts " Added setting 'cloudfront_distribution_id: #{dist_id}' into #{config_source.description}"
28
+ end
29
+
30
+ def self.print_report_on_new_dist(response_xml, dist_id, options)
31
+ config_source = options[:config_source]
32
+ domain_name = REXML::XPath.first(response_xml, '/Distribution/DomainName').get_text
33
+ puts " The distribution #{dist_id} at #{domain_name} now delivers the bucket #{config_source.s3_bucket_name}"
34
+ puts ' Please allow up to 15 minutes for the distribution to initialise'
35
+ puts ' For more information on the distribution, see https://console.aws.amazon.com/cloudfront'
36
+ if options[:verbose]
37
+ puts ' Below is the response from the CloudFront API:'
38
+ print_verbose(response_xml, left_padding = 4)
39
+ end
40
+ end
41
+
42
+ def self.print_verbose(response_xml, left_padding)
43
+ lines = []
44
+ response_xml.write(lines, 2)
45
+ padding = ""
46
+ left_padding.times { padding << " " }
47
+ puts lines.join().
48
+ gsub(/^/, "" + padding).
49
+ gsub(/\s$/, "")
50
+ end
51
+
52
+ def self.distribution_config_xml(config_source, custom_cf_settings = {})
53
+ %|
54
+ <DistributionConfig xmlns="http://cloudfront.amazonaws.com/doc/2012-07-01/">
55
+ <Origins>
56
+ <Quantity>1</Quantity>
57
+ <Items>
58
+ <Origin>
59
+ <Id>#{origin_id config_source}</Id>
60
+ <DomainName>#{config_source.s3_bucket_name}.#{Endpoint.by_config_source(config_source).hostname}</DomainName>
61
+ <S3OriginConfig>
62
+ <OriginAccessIdentity></OriginAccessIdentity>
63
+ </S3OriginConfig>
64
+ </Origin>
65
+ </Items>
66
+ </Origins>
67
+ #{
68
+ XmlHelper.hash_to_api_xml(
69
+ default_cloudfront_settings(config_source).merge custom_cf_settings
70
+ )
71
+ }
72
+ </DistributionConfig>
73
+ |
74
+ end
75
+
76
+ def self.default_cloudfront_settings(config_source)
77
+ {
78
+ 'caller_reference' => 'configure-s3-website gem ' + Time.now.to_s,
79
+ 'default_root_object' => 'index.html',
80
+ 'logging' => {
81
+ 'enabled' => 'false',
82
+ 'include_cookies' => 'false',
83
+ 'bucket' => '',
84
+ 'prefix' => ''
85
+ },
86
+ 'enabled' => 'true',
87
+ 'comment' => 'Created by the configure-s3-website gem',
88
+ 'aliases' => {
89
+ 'quantity' => '0'
90
+ },
91
+ 'default_cache_behavior' => {
92
+ 'target_origin_id' => (origin_id config_source),
93
+ 'trusted_signers' => {
94
+ 'enabled' => 'false',
95
+ 'quantity' => '0'
96
+ },
97
+ 'forwarded_values' => {
98
+ 'query_string' => 'true',
99
+ 'cookies' => {
100
+ 'forward' => 'all'
101
+ }
102
+ },
103
+ 'viewer_protocol_policy' => 'allow-all',
104
+ 'min_TTL' => (60 * 60 * 24)
105
+ },
106
+ 'cache_behaviors' => {
107
+ 'quantity' => '0'
108
+ },
109
+ 'price_class' => 'PriceClass_All'
110
+ }
111
+ end
112
+
113
+ def self.origin_id(config_source)
114
+ "#{config_source.s3_bucket_name}-S3-origin"
115
+ end
116
+ end
117
+ end
@@ -1,5 +1,8 @@
1
1
  module ConfigureS3Website
2
2
  class ConfigSource
3
+ def description
4
+ end
5
+
3
6
  def s3_access_key_id
4
7
  end
5
8
 
@@ -14,5 +17,11 @@ module ConfigureS3Website
14
17
 
15
18
  def routing_rules
16
19
  end
20
+
21
+ def cloudfront_distribution_id
22
+ end
23
+
24
+ def cloudfront_distribution_id=(dist_id)
25
+ end
17
26
  end
18
27
  end
@@ -4,7 +4,12 @@ require 'erb'
4
4
  module ConfigureS3Website
5
5
  class FileConfigSource < ConfigSource
6
6
  def initialize(yaml_file_path)
7
- @config = parse_config yaml_file_path
7
+ @yaml_file_path = yaml_file_path
8
+ @config = FileConfigSource.parse_config yaml_file_path
9
+ end
10
+
11
+ def description
12
+ @yaml_file_path
8
13
  end
9
14
 
10
15
  def s3_access_key_id
@@ -27,15 +32,26 @@ module ConfigureS3Website
27
32
  @config['routing_rules']
28
33
  end
29
34
 
35
+ def cloudfront_distribution_id
36
+ @config['cloudfront_distribution_id']
37
+ end
38
+
39
+ def cloudfront_distribution_id=(dist_id)
40
+ @config['cloudfront_distribution_id'] = dist_id
41
+ File.open(@yaml_file_path, 'w') do |yaml_file|
42
+ yaml_file.puts @config.to_yaml
43
+ end
44
+ end
45
+
30
46
  private
31
47
 
32
- def parse_config(yaml_file_path)
48
+ def self.parse_config(yaml_file_path)
33
49
  config = YAML.load(ERB.new(File.read(yaml_file_path)).result)
34
50
  validate_config(config, yaml_file_path)
35
51
  config
36
52
  end
37
53
 
38
- def validate_config(config, yaml_file_path)
54
+ def self.validate_config(config, yaml_file_path)
39
55
  required_keys = %w{s3_id s3_secret s3_bucket}
40
56
  missing_keys = required_keys.reject do |key| config.keys.include?key end
41
57
  unless missing_keys.empty?
@@ -0,0 +1,83 @@
1
+ module ConfigureS3Website
2
+ class HttpHelper
3
+ def self.call_s3_api(path, method, body, config_source)
4
+ endpoint = Endpoint.by_config_source(config_source)
5
+ date = Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S %Z")
6
+ digest = create_s3_digest(path, method, config_source, date)
7
+ self.call_api(
8
+ path, method, body, config_source, endpoint.hostname, digest, date
9
+ )
10
+ end
11
+
12
+ def self.call_cloudfront_api(path, method, body, config_source)
13
+ date = Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S %Z")
14
+ digest = create_cloudfront_digest(config_source, date)
15
+ self.call_api(
16
+ path, method, body, config_source, 'cloudfront.amazonaws.com', digest, date
17
+ )
18
+ end
19
+
20
+ private
21
+
22
+ def self.call_api(path, method, body, config_source, hostname, digest, date)
23
+ url = "https://#{hostname}#{path}"
24
+ uri = URI.parse(url)
25
+ req = method.new(uri.to_s)
26
+ req.initialize_http_header({
27
+ 'Date' => date,
28
+ 'Content-Type' => '',
29
+ 'Content-Length' => body.length.to_s,
30
+ 'Authorization' => "AWS %s:%s" % [config_source.s3_access_key_id, digest]
31
+ })
32
+ req.body = body
33
+ http = Net::HTTP.new(uri.host, uri.port)
34
+ http.use_ssl = true
35
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
36
+ res = http.request(req)
37
+ if res.code.to_i.between? 200, 299
38
+ res
39
+ else
40
+ raise ConfigureS3Website::ErrorParser.create_error res.body
41
+ end
42
+ end
43
+
44
+ def self.create_s3_digest(path, method, config_source, date)
45
+ digest = OpenSSL::Digest::Digest.new('sha1')
46
+ method_string = method.to_s.match(/Net::HTTP::(\w+)/)[1].upcase
47
+ can_string = "#{method_string}\n\n\n#{date}\n#{path}"
48
+ hmac = OpenSSL::HMAC.digest(digest, config_source.s3_secret_access_key, can_string)
49
+ signature = Base64.encode64(hmac).strip
50
+ end
51
+
52
+ def self.create_cloudfront_digest(config_source, date)
53
+ digest = Base64.encode64(
54
+ OpenSSL::HMAC.digest(
55
+ OpenSSL::Digest::Digest.new('sha1'),
56
+ config_source.s3_secret_access_key,
57
+ date
58
+ )
59
+ ).strip
60
+ end
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ module ConfigureS3Website
67
+ class ErrorParser
68
+ def self.create_error(amazon_error_xml)
69
+ error_code = amazon_error_xml.delete('\n').match(/<Code>(.*?)<\/Code>/)[1]
70
+ begin
71
+ Object.const_get("#{error_code}Error").new
72
+ rescue NameError
73
+ GenericError.new(amazon_error_xml)
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ class GenericError < StandardError
80
+ def initialize(error_message)
81
+ super("AWS API call failed:\n#{error_message}")
82
+ end
83
+ end
@@ -0,0 +1,17 @@
1
+ module ConfigureS3Website
2
+ class Runner
3
+ def self.run(options, standard_input = STDIN)
4
+ S3Client.configure_website options
5
+ unless user_already_has_cf_configured options
6
+ CloudFrontClient.create_distribution_if_user_agrees options, standard_input
7
+ end
8
+ end
9
+
10
+ private
11
+
12
+ def self.user_already_has_cf_configured(options)
13
+ config_source = options[:config_source]
14
+ config_source.cloudfront_distribution_id
15
+ end
16
+ end
17
+ end
@@ -6,7 +6,8 @@ require 'net/https'
6
6
 
7
7
  module ConfigureS3Website
8
8
  class S3Client
9
- def self.configure_website(config_source)
9
+ def self.configure_website(options)
10
+ config_source = options[:config_source]
10
11
  begin
11
12
  enable_website_configuration(config_source)
12
13
  make_bucket_readable_to_everyone(config_source)
@@ -30,7 +31,7 @@ module ConfigureS3Website
30
31
  </ErrorDocument>
31
32
  </WebsiteConfiguration>
32
33
  |
33
- call_s3_api(
34
+ HttpHelper.call_s3_api(
34
35
  path = "/#{config_source.s3_bucket_name}/?website",
35
36
  method = Net::HTTP::Put,
36
37
  body = body,
@@ -50,7 +51,7 @@ module ConfigureS3Website
50
51
  "Resource":["arn:aws:s3:::#{config_source.s3_bucket_name}/*"]
51
52
  }]
52
53
  }|
53
- call_s3_api(
54
+ HttpHelper.call_s3_api(
54
55
  path = "/#{config_source.s3_bucket_name}/?policy",
55
56
  method = Net::HTTP::Put,
56
57
  body = policy_json,
@@ -76,7 +77,7 @@ module ConfigureS3Website
76
77
  body << %|
77
78
  <RoutingRule>
78
79
  |
79
- body << self.hash_to_api_xml(routing_rule, 7)
80
+ body << XmlHelper.hash_to_api_xml(routing_rule, 7)
80
81
  body << %|
81
82
  </RoutingRule>
82
83
  |
@@ -86,7 +87,7 @@ module ConfigureS3Website
86
87
  </WebsiteConfiguration>
87
88
  |
88
89
 
89
- call_s3_api(
90
+ HttpHelper.call_s3_api(
90
91
  path = "/#{config_source.s3_bucket_name}/?website",
91
92
  method = Net::HTTP::Put,
92
93
  body = body,
@@ -110,7 +111,7 @@ module ConfigureS3Website
110
111
  |
111
112
  end
112
113
 
113
- call_s3_api(
114
+ HttpHelper.call_s3_api(
114
115
  path = "/#{config_source.s3_bucket_name}",
115
116
  method = Net::HTTP::Put,
116
117
  body = body,
@@ -122,51 +123,6 @@ module ConfigureS3Website
122
123
  endpoint.region
123
124
  ]
124
125
  end
125
-
126
- def self.call_s3_api(path, method, body, config_source)
127
- endpoint = Endpoint.new(config_source.s3_endpoint || '')
128
- date = Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S %Z")
129
- digest = create_digest(path, method, config_source, date)
130
- url = "https://#{endpoint.hostname}#{path}"
131
- uri = URI.parse(url)
132
- req = method.new(uri.to_s)
133
- req.initialize_http_header({
134
- 'Date' => date,
135
- 'Content-Type' => '',
136
- 'Content-Length' => body.length.to_s,
137
- 'Authorization' => "AWS %s:%s" % [config_source.s3_access_key_id, digest]
138
- })
139
- req.body = body
140
- http = Net::HTTP.new(uri.host, uri.port)
141
- http.use_ssl = true
142
- http.verify_mode = OpenSSL::SSL::VERIFY_NONE
143
- res = http.request(req)
144
- if res.code.to_i.between? 200, 299
145
- res
146
- else
147
- raise ConfigureS3Website::ErrorParser.create_error res.body
148
- end
149
- end
150
-
151
- def self.create_digest(path, method, config_source, date)
152
- digest = OpenSSL::Digest::Digest.new('sha1')
153
- method_string = method.to_s.match(/Net::HTTP::(\w+)/)[1].upcase
154
- can_string = "#{method_string}\n\n\n#{date}\n#{path}"
155
- hmac = OpenSSL::HMAC.digest(digest, config_source.s3_secret_access_key, can_string)
156
- signature = Base64.encode64(hmac).strip
157
- end
158
-
159
- def self.hash_to_api_xml(hash={}, indent=0)
160
- "".tap do |body|
161
- hash.each do |key, value|
162
- key_name = key.sub(/^[a-z\d]*/) { $&.capitalize }.gsub(/(?:_|(\/))([a-z\d]*)/) { $2.capitalize }
163
- value = value.is_a?(Hash) ? self.hash_to_api_xml(value, indent+1) : value
164
- body << "\n"
165
- body << " " * indent * 2 # 2-space indentation formatting for xml
166
- body << "<#{key_name}>#{value}</#{key_name}>"
167
- end
168
- end
169
- end
170
126
  end
171
127
  end
172
128
 
@@ -197,18 +153,9 @@ module ConfigureS3Website
197
153
  'sa-east-1' => { :region => 'South America (Sao Paulo)', :endpoint => 's3-sa-east-1.amazonaws.com' }
198
154
  }
199
155
  end
200
- end
201
- end
202
156
 
203
- module ConfigureS3Website
204
- class ErrorParser
205
- def self.create_error(amazon_error_xml)
206
- error_code = amazon_error_xml.delete('\n').match(/<Code>(.*?)<\/Code>/)[1]
207
- begin
208
- Object.const_get("#{error_code}Error").new
209
- rescue NameError
210
- GenericS3Error.new(amazon_error_xml)
211
- end
157
+ def self.by_config_source(config_source)
158
+ endpoint = Endpoint.new(config_source.s3_endpoint || '')
212
159
  end
213
160
  end
214
161
  end
@@ -218,9 +165,3 @@ end
218
165
 
219
166
  class NoSuchBucketError < StandardError
220
167
  end
221
-
222
- class GenericS3Error < StandardError
223
- def initialize(error_message)
224
- super("AWS API call failed:\n#{error_message}")
225
- end
226
- end