convox_installer 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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