configure-s3-website-ng 3.0.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.
@@ -0,0 +1,172 @@
1
+ require "aws-sdk"
2
+ require 'deep_merge'
3
+
4
+ module ConfigureS3Website
5
+ class CloudFrontClient
6
+ def self.apply_distribution_config(options)
7
+ config_source = options[:config_source]
8
+ puts "Detected an existing CloudFront distribution (id #{config_source.cloudfront_distribution_id}) ..."
9
+
10
+ live_config = cloudfront(config_source).get_distribution({
11
+ id: options[:config_source].cloudfront_distribution_id
12
+ })
13
+
14
+ custom_distribution_config = config_source.cloudfront_distribution_config || {}
15
+ if custom_distribution_config.empty?
16
+ return
17
+ end
18
+ live_distribution_config = live_config.distribution.distribution_config.to_hash
19
+ custom_distribution_config_with_caller_ref = live_distribution_config.deep_merge!(
20
+ deep_symbolize(custom_distribution_config.merge({
21
+ caller_reference: live_config.distribution.distribution_config.caller_reference,
22
+ comment: 'Updated by the configure-s3-website gem'
23
+ })),
24
+ ConfigureS3Website::deep_merge_options
25
+ )
26
+ cloudfront(config_source).update_distribution({
27
+ distribution_config: custom_distribution_config_with_caller_ref,
28
+ id: options[:config_source].cloudfront_distribution_id,
29
+ if_match: live_config.etag
30
+ })
31
+
32
+ print_report_on_custom_distribution_config custom_distribution_config_with_caller_ref
33
+ end
34
+
35
+ def self.create_distribution_if_user_agrees(options, standard_input)
36
+ if options['autocreate-cloudfront-dist'] and options[:headless]
37
+ puts 'Creating a CloudFront distribution for your S3 website ...'
38
+ create_distribution options
39
+ elsif options[:headless]
40
+ # Do nothing
41
+ else
42
+ puts 'Do you want to deliver your website via CloudFront, Amazon’s CDN service? [y/N]'
43
+ case standard_input.gets.chomp
44
+ when /(y|Y)/ then create_distribution options
45
+ end
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def self.cloudfront(config_source)
52
+ Aws::CloudFront::Client.new(
53
+ region: 'us-east-1',
54
+ access_key_id: config_source.s3_access_key_id,
55
+ secret_access_key: config_source.s3_secret_access_key
56
+ )
57
+ end
58
+
59
+ def self.create_distribution(options)
60
+ config_source = options[:config_source]
61
+ custom_distribution_config = config_source.cloudfront_distribution_config || {}
62
+ distribution = cloudfront(config_source).create_distribution(
63
+ deep_symbolize new_distribution_config(config_source, custom_distribution_config)
64
+ ).distribution
65
+ dist_id = distribution.id
66
+ print_report_on_new_dist distribution, dist_id, options, config_source
67
+ config_source.cloudfront_distribution_id = dist_id.to_s
68
+ puts " Added setting 'cloudfront_distribution_id: #{dist_id}' into #{config_source.description}"
69
+ unless custom_distribution_config.empty?
70
+ print_report_on_custom_distribution_config custom_distribution_config
71
+ end
72
+ end
73
+
74
+ def self.print_report_on_custom_distribution_config(custom_distribution_config, left_padding = 4)
75
+ puts ' Applied custom distribution settings:'
76
+ puts custom_distribution_config.
77
+ to_yaml.
78
+ to_s.
79
+ gsub("---\n", '').
80
+ gsub(/^/, padding(left_padding))
81
+ end
82
+
83
+ def self.print_report_on_new_dist(distribution, dist_id, options, config_source)
84
+ config_source = options[:config_source]
85
+ puts " The distribution #{dist_id} at #{distribution.domain_name} now delivers the origin #{distribution.distribution_config.origins.items[0].domain_name}"
86
+ puts ' Please allow up to 15 minutes for the distribution to initialise'
87
+ puts ' For more information on the distribution, see https://console.aws.amazon.com/cloudfront'
88
+ if options[:verbose]
89
+ puts ' Below is the response from the CloudFront API:'
90
+ puts distribution
91
+ end
92
+ end
93
+
94
+ def self.new_distribution_config(config_source, custom_cf_settings)
95
+ {
96
+ 'distribution_config' => {
97
+ 'caller_reference' => 'configure-s3-website gem ' + Time.now.to_s,
98
+ 'default_root_object' => 'index.html',
99
+ 'origins' => {
100
+ 'quantity' => 1,
101
+ 'items' => [
102
+ {
103
+ 'id' => (origin_id config_source),
104
+ 'domain_name' => "#{config_source.s3_bucket_name}.#{EndpointHelper.s3_website_hostname(config_source.s3_endpoint)}",
105
+ 'custom_origin_config' => {
106
+ 'http_port' => 80,
107
+ 'https_port' => 443,
108
+ 'origin_protocol_policy' => 'http-only'
109
+ }
110
+ }
111
+ ]
112
+ },
113
+ 'logging' => {
114
+ 'enabled' => 'false',
115
+ 'include_cookies' => 'false',
116
+ 'bucket' => '',
117
+ 'prefix' => ''
118
+ },
119
+ 'enabled' => 'true',
120
+ 'comment' => 'Created by the configure-s3-website gem',
121
+ 'aliases' => {
122
+ 'quantity' => '0'
123
+ },
124
+ 'default_cache_behavior' => {
125
+ 'target_origin_id' => (origin_id config_source),
126
+ 'trusted_signers' => {
127
+ 'enabled' => 'false',
128
+ 'quantity' => '0'
129
+ },
130
+ 'forwarded_values' => {
131
+ 'query_string' => 'true',
132
+ 'cookies' => {
133
+ 'forward' => 'all'
134
+ }
135
+ },
136
+ 'viewer_protocol_policy' => 'allow-all',
137
+ 'min_ttl' => '86400'
138
+ },
139
+ 'cache_behaviors' => {
140
+ 'quantity' => '0'
141
+ },
142
+ 'price_class' => 'PriceClass_All'
143
+ }.deep_merge!(custom_cf_settings, ConfigureS3Website::deep_merge_options)
144
+ }
145
+ end
146
+
147
+ def self.origin_id(config_source)
148
+ "#{config_source.s3_bucket_name}-S3-origin"
149
+ end
150
+
151
+ def self.padding(amount)
152
+ padding = ''
153
+ amount.times { padding << " " }
154
+ padding
155
+ end
156
+
157
+ def self.deep_symbolize(value)
158
+ if value.is_a? Hash
159
+ Hash[value.map { |k,v| [k.to_sym, deep_symbolize(v)] }]
160
+ elsif value.is_a? Array
161
+ value.map { |v| deep_symbolize(v) }
162
+ else
163
+ value
164
+ end
165
+ end
166
+ end
167
+ def self.deep_merge_options
168
+ {
169
+ :merge_hash_arrays => true
170
+ }
171
+ end
172
+ end
@@ -0,0 +1,30 @@
1
+ module ConfigureS3Website
2
+ class ConfigSource
3
+ def description
4
+ end
5
+
6
+ def s3_access_key_id
7
+ end
8
+
9
+ def s3_secret_access_key
10
+ end
11
+
12
+ def s3_bucket_name
13
+ end
14
+
15
+ def s3_endpoint
16
+ end
17
+
18
+ def routing_rules
19
+ end
20
+
21
+ def cloudfront_distribution_config
22
+ end
23
+
24
+ def cloudfront_distribution_id
25
+ end
26
+
27
+ def cloudfront_distribution_id=(dist_id)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,87 @@
1
+ require 'yaml'
2
+ require 'erb'
3
+
4
+ module ConfigureS3Website
5
+ class FileConfigSource < ConfigSource
6
+ def initialize(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
13
+ end
14
+
15
+ def s3_access_key_id
16
+ @config['s3_id']
17
+ end
18
+
19
+ def s3_secret_access_key
20
+ @config['s3_secret']
21
+ end
22
+
23
+ def profile
24
+ @config['profile']
25
+ end
26
+
27
+ def s3_bucket_name
28
+ @config['s3_bucket']
29
+ end
30
+
31
+ def s3_endpoint
32
+ endpoint = @config['s3_endpoint'] || 'us-east-1'
33
+ if endpoint == 'EU'
34
+ 'eu-west-1'
35
+ else
36
+ endpoint
37
+ end
38
+ end
39
+
40
+ def routing_rules
41
+ @config['routing_rules']
42
+ end
43
+
44
+ def index_document
45
+ @config['index_document']
46
+ end
47
+
48
+ def error_document
49
+ @config['error_document']
50
+ end
51
+
52
+ def cloudfront_distribution_config
53
+ @config['cloudfront_distribution_config']
54
+ end
55
+
56
+ def cloudfront_distribution_id
57
+ @config['cloudfront_distribution_id']
58
+ end
59
+
60
+ def cloudfront_distribution_id=(dist_id)
61
+ @config['cloudfront_distribution_id'] = dist_id
62
+ file_contents = File.open(@yaml_file_path).read
63
+ File.open(@yaml_file_path, 'w') do |file|
64
+ result = file_contents.gsub(
65
+ /(s3_bucket:.*$)/,
66
+ "\\1\ncloudfront_distribution_id: #{dist_id}"
67
+ )
68
+ file.write result
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def self.parse_config(yaml_file_path)
75
+ config = YAML.load(ERB.new(File.read(yaml_file_path)).result)
76
+ validate_config(config, yaml_file_path)
77
+ config
78
+ end
79
+
80
+ def self.validate_config(config, yaml_file_path)
81
+ # make sure the bucket name is configured at a minimum
82
+ if not config.keys.include?'s3_bucket'
83
+ raise "File #{yaml_file_path} does not contain the required key 's3_bucket'"
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,26 @@
1
+ module ConfigureS3Website
2
+ class EndpointHelper
3
+ def self.s3_website_hostname(region)
4
+ if old_regions.include?(region)
5
+ "s3-website-#{region}.amazonaws.com"
6
+ else
7
+ "s3-website.#{region}.amazonaws.com"
8
+ end
9
+ end
10
+
11
+ private
12
+
13
+ def self.old_regions
14
+ [
15
+ 'us-east-1',
16
+ 'us-west-1',
17
+ 'us-west-2',
18
+ 'ap-southeast-1',
19
+ 'ap-southeast-2',
20
+ 'ap-northeast-1',
21
+ 'eu-west-1',
22
+ 'sa-east-1'
23
+ ]
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,74 @@
1
+ module ConfigureS3Website
2
+ class HttpHelper
3
+ def self.call_cloudfront_api(path, method, body, config_source, headers = {})
4
+ date = Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S %Z")
5
+ digest = create_cloudfront_digest(config_source, date)
6
+ self.call_api(
7
+ path,
8
+ method,
9
+ body,
10
+ config_source,
11
+ 'cloudfront.amazonaws.com',
12
+ digest,
13
+ date,
14
+ headers
15
+ )
16
+ end
17
+
18
+ private
19
+
20
+ def self.call_api(path, method, body, config_source, hostname, digest, date, additional_headers = {})
21
+ url = "https://#{hostname}#{path}"
22
+ uri = URI.parse(url)
23
+ req = method.new(uri.to_s)
24
+ req.initialize_http_header({
25
+ 'Date' => date,
26
+ 'Content-Type' => '',
27
+ 'Content-Length' => body.length.to_s,
28
+ 'Authorization' => "AWS %s:%s" % [config_source.s3_access_key_id, digest]
29
+ }.merge(additional_headers))
30
+ req.body = body
31
+ http = Net::HTTP.new(uri.host, uri.port)
32
+ # http.set_debug_output $stderr
33
+ http.use_ssl = true
34
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
35
+ res = http.request(req)
36
+ if res.code.to_i.between? 200, 299
37
+ res
38
+ else
39
+ raise ConfigureS3Website::ErrorParser.create_error res.body
40
+ end
41
+ end
42
+
43
+ def self.create_cloudfront_digest(config_source, date)
44
+ digest = Base64.encode64(
45
+ OpenSSL::HMAC.digest(
46
+ OpenSSL::Digest.new('sha1'),
47
+ config_source.s3_secret_access_key,
48
+ date
49
+ )
50
+ ).strip
51
+ end
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ module ConfigureS3Website
58
+ class ErrorParser
59
+ def self.create_error(amazon_error_xml)
60
+ error_code = amazon_error_xml.delete('\n').match(/<Code>(.*?)<\/Code>/)[1]
61
+ begin
62
+ Object.const_get("#{error_code}Error").new
63
+ rescue NameError
64
+ GenericError.new(amazon_error_xml)
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ class GenericError < StandardError
71
+ def initialize(error_message)
72
+ super("AWS API call failed:\n#{error_message}")
73
+ end
74
+ end
@@ -0,0 +1,31 @@
1
+ module ConfigureS3Website
2
+ class Runner
3
+ def self.run(options, standard_input = STDIN)
4
+ S3Client.configure_website options
5
+ maybe_create_or_update_cloudfront options, standard_input
6
+ end
7
+
8
+ private
9
+
10
+ def self.maybe_create_or_update_cloudfront(options, standard_input)
11
+ unless user_already_has_cf_configured options
12
+ CloudFrontClient.create_distribution_if_user_agrees options, standard_input
13
+ return
14
+ end
15
+ if user_already_has_cf_configured(options) and user_has_custom_cf_dist_config(options)
16
+ CloudFrontClient.apply_distribution_config options
17
+ return
18
+ end
19
+ end
20
+
21
+ def self.user_already_has_cf_configured(options)
22
+ config_source = options[:config_source]
23
+ config_source.cloudfront_distribution_id
24
+ end
25
+
26
+ def self.user_has_custom_cf_dist_config(options)
27
+ config_source = options[:config_source]
28
+ config_source.cloudfront_distribution_config
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,129 @@
1
+ require 'base64'
2
+ require 'openssl'
3
+ require 'digest/sha1'
4
+ require 'digest/md5'
5
+ require 'net/https'
6
+ require 'aws-sdk'
7
+
8
+ module ConfigureS3Website
9
+ class S3Client
10
+ def self.configure_website(options)
11
+ config_source = options[:config_source]
12
+ begin
13
+ enable_website_configuration(config_source)
14
+ make_bucket_readable_to_everyone(config_source)
15
+ rescue Aws::S3::Errors::NoSuchBucket
16
+ create_bucket(config_source)
17
+ retry
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def self.s3(config_source)
24
+ if config_source.s3_access_key_id
25
+ Aws::S3::Client.new(
26
+ region: config_source.s3_endpoint,
27
+ access_key_id: config_source.s3_access_key_id,
28
+ secret_access_key: config_source.s3_secret_access_key
29
+ )
30
+ elsif config_source.profile
31
+ Aws::S3::Client.new(
32
+ region: config_source.s3_endpoint,
33
+ profile: config_source.profile,
34
+ )
35
+ else
36
+ Aws::S3::Client.new(
37
+ region: config_source.s3_endpoint,
38
+ )
39
+ end
40
+ end
41
+
42
+ def self.enable_website_configuration(config_source)
43
+ routing_rules =
44
+ if config_source.routing_rules && config_source.routing_rules.is_a?(Array)
45
+ config_source.routing_rules.map { |rule|
46
+ Hash[
47
+ rule.map { |rule_key, rule_value|
48
+ [
49
+ rule_key.to_sym,
50
+ Hash[ # Assume that each rule value is a Hash
51
+ rule_value.map { |redirect_rule_key, redirect_rule_value|
52
+ [
53
+ redirect_rule_key.to_sym,
54
+ redirect_rule_key == "http_redirect_code" || redirect_rule_key == "http_error_code_returned_equals" ?
55
+ redirect_rule_value.to_s
56
+ #redirect_rule_value.to_s # Permit numeric redirect values in the YAML config file. (The S3 API does not allow numeric redirect values, hence this block of code.)
57
+ :
58
+ redirect_rule_value
59
+ ]
60
+ }
61
+ ]
62
+ ]
63
+ }
64
+ ]
65
+ }
66
+ else
67
+ nil
68
+ end
69
+ s3(config_source).put_bucket_website({
70
+ bucket: config_source.s3_bucket_name,
71
+ website_configuration: {
72
+ index_document: {
73
+ suffix: config_source.index_document || "index.html"
74
+ },
75
+ error_document: {
76
+ key: config_source.error_document || "error.html"
77
+ },
78
+ routing_rules: routing_rules
79
+ }
80
+ })
81
+ puts "Bucket #{config_source.s3_bucket_name} now functions as a website"
82
+ if routing_rules && routing_rules.any?
83
+ puts "#{routing_rules.size} redirects configured for #{config_source.s3_bucket_name} bucket"
84
+ else
85
+ puts "No redirects to configure for #{config_source.s3_bucket_name} bucket"
86
+ end
87
+ end
88
+
89
+ def self.make_bucket_readable_to_everyone(config_source)
90
+ s3(config_source).delete_public_access_block({
91
+ bucket: config_source.s3_bucket_name
92
+ })
93
+ s3(config_source).put_bucket_policy({
94
+ bucket: config_source.s3_bucket_name,
95
+ policy: %|{
96
+ "Version":"2008-10-17",
97
+ "Statement":[{
98
+ "Sid":"PublicReadForGetBucketObjects",
99
+ "Effect":"Allow",
100
+ "Principal": { "AWS": "*" },
101
+ "Action":["s3:GetObject"],
102
+ "Resource":["arn:aws:s3:::#{config_source.s3_bucket_name}/*"]
103
+ }]
104
+ }|
105
+ })
106
+ puts "Bucket #{config_source.s3_bucket_name} is now readable to the whole world"
107
+ end
108
+
109
+ def self.create_bucket(config_source)
110
+ s3(config_source).create_bucket({
111
+ bucket: config_source.s3_bucket_name,
112
+ create_bucket_configuration:
113
+ if config_source.s3_endpoint && config_source.s3_endpoint != 'us-east-1'
114
+ {
115
+ location_constraint: config_source.s3_endpoint
116
+ }
117
+ else
118
+ nil
119
+ end
120
+ })
121
+ puts "Created bucket %s in the %s Region" %
122
+ [
123
+ config_source.s3_bucket_name,
124
+ config_source.s3_endpoint
125
+ ]
126
+ end
127
+ end
128
+ end
129
+
@@ -0,0 +1,3 @@
1
+ module ConfigureS3Website
2
+ VERSION = '3.0.0'
3
+ end
@@ -0,0 +1,15 @@
1
+ module ConfigureS3Website
2
+ class XmlHelper
3
+ def self.hash_to_api_xml(hash={}, indent=0)
4
+ "".tap do |body|
5
+ hash.each do |key, value|
6
+ key_name = key.sub(/^[a-z\d]*/) { $&.capitalize }.gsub(/(?:_|(\/))([a-z\d]*)/) { $2.capitalize }
7
+ value = value.is_a?(Hash) ? self.hash_to_api_xml(value, indent+1) : value
8
+ body << "\n"
9
+ body << " " * indent * 2 # 2-space indentation formatting for xml
10
+ body << "<#{key_name}>#{value}</#{key_name}>"
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,10 @@
1
+ require 'configure-s3-website/version'
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/endpoint_helper'
7
+ require 'configure-s3-website/runner'
8
+ require 'configure-s3-website/cli'
9
+ require 'configure-s3-website/config_source/config_source'
10
+ require 'configure-s3-website/config_source/file_config_source'