convox_installer 1.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,402 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "json"
5
+ require "fileutils"
6
+
7
+ module Convox
8
+ class Client
9
+ CONVOX_DIR = File.expand_path("~/.convox").freeze
10
+ AUTH_FILE = File.join(CONVOX_DIR, "auth")
11
+ HOST_FILE = File.join(CONVOX_DIR, "host")
12
+
13
+ attr_accessor :logger, :config
14
+
15
+ def auth
16
+ load_auth_from_file
17
+ end
18
+
19
+ def initialize(options = {})
20
+ @logger = Logger.new(STDOUT)
21
+ logger.level = options[:log_level] || Logger::INFO
22
+ @config = options[:config] || {}
23
+ end
24
+
25
+ def backup_convox_host_and_rack
26
+ %w[host rack].each do |f|
27
+ path = File.join(CONVOX_DIR, f)
28
+ if File.exist?(path)
29
+ bak_file = "#{path}.bak"
30
+ logger.info "Moving existing #{path} to #{bak_file}..."
31
+ FileUtils.mv(path, bak_file)
32
+ end
33
+ end
34
+ end
35
+
36
+ def install_convox
37
+ require_config(%i[ aws_region stack_name ])
38
+ region = config.fetch(:aws_region)
39
+ stack_name = config.fetch(:stack_name)
40
+
41
+ if rack_already_installed?
42
+ logger.info "There is already a Convox stack named #{stack_name} " \
43
+ "in the #{region} AWS region. Using this rack. "
44
+ return true
45
+ end
46
+
47
+ require_config(%i[
48
+ aws_region
49
+ aws_access_key_id
50
+ aws_secret_access_key
51
+ stack_name
52
+ instance_type
53
+ ])
54
+
55
+ logger.info "Installing Convox (#{stack_name})..."
56
+
57
+ env = {
58
+ "AWS_REGION" => region,
59
+ "AWS_ACCESS_KEY_ID" => config.fetch(:aws_access_key_id),
60
+ "AWS_SECRET_ACCESS_KEY" => config.fetch(:aws_secret_access_key),
61
+ }
62
+ command = %Q{rack install aws \
63
+ --name "#{config.fetch(:stack_name)}" \
64
+ "InstanceType=#{config.fetch(:instance_type)}" \
65
+ "BuildInstance="}
66
+
67
+ run_convox_command!(command, env)
68
+ end
69
+
70
+ def rack_already_installed?
71
+ require_config(%i[ aws_region stack_name ])
72
+
73
+ unless File.exist?(AUTH_FILE)
74
+ raise "Could not find auth file at #{AUTH_FILE}!"
75
+ end
76
+
77
+ region = config.fetch(:aws_region)
78
+ stack_name = config.fetch(:stack_name)
79
+
80
+ auth.each do |host, password|
81
+ if host.match?(/^#{stack_name}-\d+\.#{region}\.elb\.amazonaws\.com$/)
82
+ return true
83
+ end
84
+ end
85
+ false
86
+ end
87
+
88
+ def validate_convox_auth_and_set_host!
89
+ require_config(%i[ aws_region stack_name ])
90
+
91
+ unless File.exist?(AUTH_FILE)
92
+ raise "Could not find auth file at #{AUTH_FILE}!"
93
+ end
94
+
95
+ region = config.fetch(:aws_region)
96
+ stack = config.fetch(:stack_name)
97
+
98
+ match_count = 0
99
+ matching_host = nil
100
+ auth.each do |host, password|
101
+ if host.match?(/^#{stack}-\d+\.#{region}\.elb\.amazonaws\.com$/)
102
+ matching_host = host
103
+ match_count += 1
104
+ end
105
+ end
106
+
107
+ if match_count == 1
108
+ set_host(matching_host)
109
+ return matching_host
110
+ end
111
+
112
+ if match_count > 1
113
+ error_message = "Found multiple matching hosts for "
114
+ else
115
+ error_message = "Could not find matching authentication for "
116
+ end
117
+ error_message += "region: #{region}, stack: #{stack}"
118
+ raise error_message
119
+ end
120
+
121
+ def set_host(host)
122
+ logger.debug "Setting convox host to #{host} (in #{HOST_FILE})..."
123
+ File.open(HOST_FILE, "w") { |f| f.puts host }
124
+ end
125
+
126
+ def validate_convox_rack!
127
+ require_config(%i[
128
+ aws_region
129
+ stack_name
130
+ instance_type
131
+ ])
132
+ logger.debug "Validating that convox rack has the correct attributes..."
133
+ {
134
+ provider: "aws",
135
+ region: config.fetch(:aws_region),
136
+ type: config.fetch(:instance_type),
137
+ name: config.fetch(:stack_name),
138
+ }.each do |k, v|
139
+ convox_value = convox_rack_data[k.to_s]
140
+ if convox_value != v
141
+ raise "Convox data did not match! Expected #{k} to be '#{v}', " \
142
+ "but was: '#{convox_value}'"
143
+ end
144
+ end
145
+ logger.debug "=> Convox rack has the correct attributes."
146
+ true
147
+ end
148
+
149
+ def convox_rack_data
150
+ @convox_rack_data ||= begin
151
+ logger.debug "Fetching convox rack attributes..."
152
+ convox_output = `convox api get /system`
153
+ raise "convox command failed!" unless $?.success?
154
+ JSON.parse(convox_output)
155
+ end
156
+ end
157
+
158
+ def create_convox_app!
159
+ require_config(%i[convox_app_name])
160
+ return true if convox_app_exists?
161
+
162
+ app_name = config.fetch(:convox_app_name)
163
+
164
+ logger.info "Creating app: #{app_name}..."
165
+ logger.info "=> Documentation: " \
166
+ "https://docs.convox.com/deployment/creating-an-application"
167
+
168
+ run_convox_command! "apps create #{app_name} --wait"
169
+
170
+ retries = 0
171
+ loop do
172
+ break if convox_app_exists?
173
+ if retries > 5
174
+ raise "Something went wrong while creating the #{app_name} app! " \
175
+ "(Please wait a few moments and then restart the installation script.)"
176
+ end
177
+ logger.info "Waiting for #{app_name} to be ready..."
178
+ sleep 3
179
+ retries += 1
180
+ end
181
+
182
+ logger.info "=> #{app_name} app created!"
183
+ end
184
+
185
+ def set_default_app_for_directory!
186
+ logger.info "Setting default app in ./.convox/app..."
187
+ FileUtils.mkdir_p File.expand_path("./.convox")
188
+ File.open(File.expand_path("./.convox/app"), "w") do |f|
189
+ f.puts config.fetch(:convox_app_name)
190
+ end
191
+ end
192
+
193
+ def convox_app_exists?
194
+ require_config(%i[convox_app_name])
195
+ app_name = config.fetch(:convox_app_name)
196
+
197
+ logger.debug "Looking for existing #{app_name} app..."
198
+ convox_output = `convox api get /apps`
199
+ raise "convox command failed!" unless $?.success?
200
+
201
+ apps = JSON.parse(convox_output)
202
+ apps.each do |app|
203
+ if app["name"] == app_name
204
+ logger.debug "=> Found #{app_name} app."
205
+ return true
206
+ end
207
+ end
208
+ logger.debug "=> Did not find #{app_name} app."
209
+ false
210
+ end
211
+
212
+ # Create the s3 bucket, and also apply a CORS configuration
213
+ def create_s3_bucket!
214
+ require_config(%i[s3_bucket_name])
215
+ bucket_name = config.fetch(:s3_bucket_name)
216
+ if s3_bucket_exists?
217
+ logger.info "#{bucket_name} S3 bucket already exists!"
218
+ else
219
+ logger.info "Creating S3 bucket resource (#{bucket_name})..."
220
+ run_convox_command! "rack resources create s3 " \
221
+ "--name \"#{bucket_name}\" " \
222
+ "--wait"
223
+
224
+ retries = 0
225
+ loop do
226
+ return if s3_bucket_exists?
227
+
228
+ if retries > 10
229
+ raise "Something went wrong while creating the #{bucket_name} S3 bucket! " \
230
+ "(Please wait a few moments and then restart the installation script.)"
231
+ end
232
+ logger.debug "Waiting for S3 bucket to be ready..."
233
+ sleep 3
234
+ retries += 1
235
+ end
236
+
237
+ logger.debug "=> S3 bucket created!"
238
+ end
239
+
240
+ set_s3_bucket_cors_policy
241
+ end
242
+
243
+ def s3_bucket_exists?
244
+ require_config(%i[s3_bucket_name])
245
+ bucket_name = config.fetch(:s3_bucket_name)
246
+ logger.debug "Looking up S3 bucket resource: #{bucket_name}"
247
+ `convox api get /resources/#{bucket_name} 2>/dev/null`
248
+ $?.success?
249
+ end
250
+
251
+ def s3_bucket_details
252
+ require_config(%i[s3_bucket_name])
253
+ @s3_bucket_details ||= begin
254
+ bucket_name = config.fetch(:s3_bucket_name)
255
+ logger.debug "Fetching S3 bucket resource details for #{bucket_name}..."
256
+
257
+ response = `convox api get /resources/#{bucket_name}`
258
+ raise "convox command failed!" unless $?.success?
259
+
260
+ bucket_data = JSON.parse(response)
261
+ s3_url = bucket_data["url"]
262
+ matches = s3_url.match(
263
+ /^s3:\/\/(?<access_key_id>[^:]*):(?<secret_access_key>[^@]*)@(?<bucket_name>.*)$/
264
+ )
265
+
266
+ match_keys = %i[access_key_id secret_access_key bucket_name]
267
+ unless matches && match_keys.all? { |k| matches[k].present? }
268
+ raise "#{s3_url} is an invalid S3 URL!"
269
+ end
270
+
271
+ {
272
+ access_key_id: matches[:access_key_id],
273
+ secret_access_key: matches[:secret_access_key],
274
+ name: matches[:bucket_name],
275
+ }
276
+ end
277
+ end
278
+
279
+ def set_s3_bucket_cors_policy
280
+ require_config(%i[aws_access_key_id aws_secret_access_key])
281
+ access_key_id = config.fetch(:aws_access_key_id)
282
+ secret_access_key = config.fetch(:aws_secret_access_key)
283
+
284
+ unless config.key? :s3_bucket_cors_policy
285
+ logger.debug "No CORS policy provided in config: s3_bucket_cors_policy"
286
+ return
287
+ end
288
+ cors_policy_string = config.fetch(:s3_bucket_cors_policy)
289
+
290
+ bucket_name = s3_bucket_details[:name]
291
+
292
+ logger.debug "Looking up existing CORS policy for #{bucket_name}"
293
+ existing_cors_policy_string =
294
+ `AWS_ACCESS_KEY_ID=#{access_key_id} \
295
+ AWS_SECRET_ACCESS_KEY=#{secret_access_key} \
296
+ aws s3api get-bucket-cors --bucket #{bucket_name} 2>/dev/null`
297
+ if $?.success? && existing_cors_policy_string.present?
298
+ # Sort all the nested arrays so that the equality operator works
299
+ existing_cors_policy = JSON.parse(existing_cors_policy_string)
300
+ cors_policy_json = JSON.parse(cors_policy_string)
301
+ [existing_cors_policy, cors_policy_json].each do |policy_json|
302
+ if policy_json.is_a?(Hash) && policy_json["CORSRules"]
303
+ policy_json["CORSRules"].each do |rule|
304
+ rule["AllowedHeaders"].sort! if rule["AllowedHeaders"]
305
+ rule["AllowedMethods"].sort! if rule["AllowedMethods"]
306
+ rule["AllowedOrigins"].sort! if rule["AllowedOrigins"]
307
+ end
308
+ end
309
+ end
310
+
311
+ if existing_cors_policy == cors_policy_json
312
+ logger.debug "=> CORS policy is already up to date for #{bucket_name}."
313
+ return
314
+ end
315
+ end
316
+
317
+ begin
318
+ logger.info "Setting CORS policy for #{bucket_name}..."
319
+
320
+ File.open("cors-policy.json", "w") { |f| f.puts cors_policy_string }
321
+
322
+ `AWS_ACCESS_KEY_ID=#{access_key_id} \
323
+ AWS_SECRET_ACCESS_KEY=#{secret_access_key} \
324
+ aws s3api put-bucket-cors \
325
+ --bucket #{bucket_name} \
326
+ --cors-configuration "file://cors-policy.json"`
327
+ unless $?.success?
328
+ raise "Something went wrong while setting the S3 bucket CORS policy!"
329
+ end
330
+ logger.info "=> Successfully set CORS policy for #{bucket_name}."
331
+ ensure
332
+ FileUtils.rm_f "cors-policy.json"
333
+ end
334
+ end
335
+
336
+ def add_docker_registry!
337
+ require_config(%i[docker_registry_url docker_registry_username docker_registry_password])
338
+
339
+ registry_url = config.fetch(:docker_registry_url)
340
+
341
+ logger.debug "Looking up existing Docker registries..."
342
+ registries_response = `convox api get /registries`
343
+ unless $?.success?
344
+ raise "Something went wrong while fetching the list of registries!"
345
+ end
346
+ registries = JSON.parse(registries_response)
347
+
348
+ if registries.any? { |r| r["server"] == registry_url }
349
+ logger.debug "=> Docker Registry already exists: #{registry_url}"
350
+ return true
351
+ end
352
+
353
+ logger.info "Adding Docker Registry: #{registry_url}..."
354
+ logger.info "=> Documentation: " \
355
+ "https://docs.convox.com/deployment/private-registries"
356
+
357
+ `convox registries add "#{registry_url}" \
358
+ "#{config.fetch(:docker_registry_username)}" \
359
+ "#{config.fetch(:docker_registry_password)}"`
360
+ unless $?.success?
361
+ raise "Something went wrong while adding the #{registry_url} registry!"
362
+ end
363
+ end
364
+
365
+ def default_service_domain_name
366
+ require_config(%i[convox_app_name default_service])
367
+
368
+ @default_service_domain_name ||= begin
369
+ convox_domain = convox_rack_data["domain"]
370
+ elb_name_and_region = convox_domain[/([^\.]*\.[^\.]*)\..*/, 1]
371
+ unless elb_name_and_region.present?
372
+ raise "Something went wrong while parsing the ELB name and region! " \
373
+ "(#{elb_name_and_region})"
374
+ end
375
+ app = config.fetch(:convox_app_name)
376
+ service = config.fetch(:default_service)
377
+
378
+ "#{app}-#{service}.#{elb_name_and_region}.convox.site"
379
+ end
380
+ end
381
+
382
+ def run_convox_command!(cmd, env = {})
383
+ command = "convox #{cmd}"
384
+ system env, command
385
+ raise "Error running: #{command}" unless $?.success?
386
+ end
387
+
388
+ private
389
+
390
+ def load_auth_from_file
391
+ return {} unless File.exist?(AUTH_FILE)
392
+
393
+ JSON.parse(File.read(AUTH_FILE))
394
+ end
395
+
396
+ def require_config(required_keys)
397
+ required_keys.each do |k|
398
+ raise "#{k} is missing from the config!" unless config[k]
399
+ end
400
+ end
401
+ end
402
+ end
data/lib/convox.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "convox/client"
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "highline"
4
+ require "fileutils"
5
+ require "json"
6
+ require "securerandom"
7
+
8
+ module ConvoxInstaller
9
+ class Config
10
+ attr_accessor :logger, :config, :prompts, :highline
11
+
12
+ CONFIG_FILE = File.expand_path("~/.convox/installer_config").freeze
13
+
14
+ DEFAULT_PROMPTS = [
15
+ {
16
+ key: :stack_name,
17
+ title: "Convox Stack Name",
18
+ prompt: "Please enter a name for your Convox installation",
19
+ default: "convox",
20
+ },
21
+ {
22
+ key: :aws_region,
23
+ title: "AWS Region",
24
+ default: "us-east-1",
25
+ },
26
+ {
27
+ key: :instance_type,
28
+ title: "EC2 Instance Type",
29
+ default: "t3.medium",
30
+ },
31
+ {
32
+ section: "Admin AWS Credentials",
33
+ },
34
+ {
35
+ key: :aws_access_key_id,
36
+ title: "AWS Access Key ID",
37
+ },
38
+ {
39
+ key: :aws_secret_access_key,
40
+ title: "AWS Secret Access Key",
41
+ },
42
+ ].freeze
43
+
44
+ def initialize(options = {})
45
+ @logger = Logger.new(STDOUT)
46
+ logger.level = options[:log_level] || Logger::INFO
47
+
48
+ self.prompts = options[:prompts] || DEFAULT_PROMPTS
49
+ self.config = {}
50
+ load_config_from_file
51
+ load_config_from_env
52
+ self.config = config.merge((options[:config] || {}).symbolize_keys)
53
+
54
+ self.highline = options[:highline] || HighLine.new
55
+ end
56
+
57
+ def config_keys
58
+ prompts.map { |prompt| prompt[:key] }.compact.map(&:to_sym)
59
+ end
60
+
61
+ def prompt_for_config
62
+ loop do
63
+ prompts.each do |prompt|
64
+ if prompt[:section]
65
+ highline.say "\n#{prompt[:section]}"
66
+ highline.say "============================================\n\n"
67
+ end
68
+ next unless prompt[:key]
69
+
70
+ ask_prompt(prompt)
71
+ end
72
+
73
+ show_config_summary
74
+
75
+ @completed_prompt = true
76
+
77
+ highline.say "Please double check all of these configuration details."
78
+
79
+ agree = highline.agree(
80
+ "Would you like to start the Convox installation?" \
81
+ " (press 'n' to correct any settings)"
82
+ )
83
+ break if agree
84
+ highline.say "\n"
85
+ end
86
+
87
+ config
88
+ end
89
+
90
+ def show_config_summary
91
+ highline.say "\n============================================"
92
+ highline.say " SUMMARY"
93
+ highline.say "============================================\n\n"
94
+
95
+ config_titles = prompts.map do |prompt|
96
+ prompt[:title] || prompt[:key]
97
+ end.compact
98
+ max = config_titles.map(&:length).max
99
+
100
+ prompts.each do |prompt|
101
+ next if !prompt[:key] || prompt[:hidden]
102
+
103
+ value = config[prompt[:key]]
104
+ title = prompt[:title] || prompt[:key]
105
+ padded_key = "#{title}:".ljust(max + 3)
106
+ highline.say " #{padded_key} #{value}"
107
+ end
108
+ highline.say "\nWe've saved your configuration to: #{CONFIG_FILE}"
109
+ highline.say "If anything goes wrong during the installation, " \
110
+ "you can restart the script to reload the config and continue.\n\n"
111
+ end
112
+
113
+ private
114
+
115
+ def ask_prompt(prompt)
116
+ key = prompt[:key]
117
+ title = prompt[:title] || key
118
+
119
+ # If looping through the config again, ask for all
120
+ # the config with defaults.
121
+ if config[key] && !@completed_prompt
122
+ logger.debug "Found existing config for #{key} => #{config[key]}"
123
+ return
124
+ end
125
+
126
+ # Used when we want to force a default value and not prompt the user.
127
+ # (e.g. securely generated passwords)
128
+ if prompt[:value]
129
+ return if config[key]
130
+
131
+ default = prompt[:value]
132
+ config[key] = default.is_a?(Proc) ? default.call : default
133
+ save_config_to_file
134
+ return
135
+ end
136
+
137
+ prompt_string = prompt[:prompt] || "Please enter your #{title}: "
138
+
139
+ config[key] = highline.ask(prompt_string) do |q|
140
+ if @completed_prompt
141
+ q.default = config[key]
142
+ elsif prompt[:default]
143
+ q.default = prompt[:default]
144
+ end
145
+ q.validate = /.+/
146
+ end
147
+
148
+ save_config_to_file
149
+ end
150
+
151
+ def load_config_from_file
152
+ return unless Config.config_file_exists?
153
+
154
+ logger.debug "Loading saved config from #{CONFIG_FILE}..."
155
+
156
+ loaded_config = JSON.parse(Config.read_config_file)["config"].symbolize_keys
157
+ self.config = config.merge(loaded_config).slice(*config_keys)
158
+ end
159
+
160
+ def load_config_from_env
161
+ config_keys.each do |key|
162
+ env_key = key.to_s.upcase
163
+ value = ENV[env_key]
164
+ next unless value.present?
165
+
166
+ logger.debug "Found value for #{key} in env var: #{env_key} => #{value}"
167
+ config[key] = value
168
+ end
169
+ end
170
+
171
+ def save_config_to_file
172
+ FileUtils.mkdir_p File.expand_path("~/.convox")
173
+ File.open(CONFIG_FILE, "w") do |f|
174
+ f.puts({config: config}.to_json)
175
+ end
176
+ end
177
+
178
+ def self.config_file_exists?
179
+ File.exist?(CONFIG_FILE)
180
+ end
181
+
182
+ def self.read_config_file
183
+ File.read(CONFIG_FILE)
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "highline"
4
+ require "os"
5
+ require "logger"
6
+
7
+ module ConvoxInstaller
8
+ class Requirements
9
+ attr_accessor :ecr_label, :logger
10
+
11
+ def initialize(options = {})
12
+ @ecr_label = options[:ecr_label]
13
+ @logger = Logger.new(STDOUT)
14
+ logger.level = options[:log_level] || Logger::INFO
15
+ end
16
+
17
+ def ensure_requirements!
18
+ logger.debug "Checking for required commands..."
19
+
20
+ @missing_packages = []
21
+ unless has_command? "convox"
22
+ @missing_packages << {
23
+ name: "convox",
24
+ brew: "convox",
25
+ docs: "https://docs.convox.com/introduction/installation",
26
+ }
27
+ end
28
+
29
+ unless has_command? "aws"
30
+ @missing_packages << {
31
+ name: "aws",
32
+ brew: "awscli",
33
+ docs: "https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-install.html",
34
+ }
35
+ end
36
+
37
+ if @missing_packages.any?
38
+ logger.error "This script requires the convox and AWS CLI tools."
39
+ if OS.mac?
40
+ logger.error "Please run: brew install " \
41
+ "#{@missing_packages.map { |p| p[:brew] }.join(" ")}"
42
+ else
43
+ logger.error "Installation Instructions:"
44
+ @missing_packages.each do |package|
45
+ logger.error "* #{package[:name]}: #{package[:docs]}"
46
+ end
47
+ end
48
+ quit!
49
+ end
50
+ end
51
+
52
+ def has_command?(command)
53
+ path = find_command command
54
+ if path.present?
55
+ logger.debug "=> Found #{command}: #{path}"
56
+ return true
57
+ end
58
+ logger.debug "=> Could not find #{command}!"
59
+ false
60
+ end
61
+
62
+ # Stubbed in tests
63
+ def find_command(command)
64
+ `which #{command} 2>/dev/null`.chomp
65
+ end
66
+
67
+ def quit!
68
+ exit 1
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConvoxInstaller
4
+ VERSION = '1.0.0'
5
+ end