configure-s3-website 1.2.0 → 1.3.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.
@@ -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