lib/convox/client.rb in convox_installer-2.0.0 vs lib/convox/client.rb in convox_installer-3.0.0
- old
+ new
@@ -2,17 +2,25 @@
require 'logger'
require 'json'
require 'fileutils'
require 'rubygems'
+require 'os'
+require 'erb'
module Convox
class Client
- CONVOX_DIR = File.expand_path('~/.convox').freeze
- AUTH_FILE = File.join(CONVOX_DIR, 'auth')
- HOST_FILE = File.join(CONVOX_DIR, 'host')
+ CONVOX_CONFIG_DIR = if OS.mac?
+ # Convox v3 moved this to ~/Library/Preferences/convox/ on Mac
+ File.expand_path('~/Library/Preferences/convox').freeze
+ else
+ File.expand_path('~/.convox').freeze
+ end
+ AUTH_FILE = File.join(CONVOX_CONFIG_DIR, 'auth')
+ CURRENT_FILE = File.join(CONVOX_CONFIG_DIR, 'current')
+
attr_accessor :logger, :config
def cli_version_string
return @cli_version_string if @cli_version_string
@@ -55,31 +63,39 @@
@logger = Logger.new($stdout)
logger.level = options[:log_level] || Logger::INFO
@config = options[:config] || {}
end
+ # Convox v3 creates a folder for each rack for the Terraform config
+ def rack_dir
+ stack_name = config.fetch(:stack_name)
+ File.join(CONVOX_CONFIG_DIR, 'racks', stack_name)
+ end
+
def backup_convox_host_and_rack
- FileUtils.mkdir_p CONVOX_DIR
+ FileUtils.mkdir_p CONVOX_CONFIG_DIR
- %w[host rack].each do |f|
- path = File.join(CONVOX_DIR, f)
- next unless File.exist?(path)
+ path = File.join(CONVOX_CONFIG_DIR, 'current')
+ return unless File.exist?(path)
- bak_file = "#{path}.bak"
- logger.info "Moving existing #{path} to #{bak_file}..."
- FileUtils.mv(path, bak_file)
- end
+ bak_file = "#{path}.bak"
+ logger.info "Moving existing #{path} to #{bak_file}..."
+ FileUtils.mv(path, bak_file)
end
def install_convox
require_config(%i[aws_region stack_name])
region = config.fetch(:aws_region)
stack_name = config.fetch(:stack_name)
if rack_already_installed?
- logger.info "There is already a Convox stack named #{stack_name} " \
- "in the #{region} AWS region. Using this rack. "
+ logger.info "There is already a Convox rack named #{stack_name}. Using this rack."
+ logger.debug 'If you need to start over, you can run: ' \
+ "convox rack uninstall #{stack_name} " \
+ '(Make sure you export AWS_ACCESS_KEY_ID and ' \
+ "AWS_SECRET_ACCESS_KEY first.)\n" \
+ "If this fails, you can try deleting the rack directory: rm -rf #{rack_dir}"
return true
end
require_config(%i[
aws_region
@@ -95,82 +111,75 @@
'AWS_REGION' => region,
'AWS_ACCESS_KEY_ID' => config.fetch(:aws_access_key_id),
'AWS_SECRET_ACCESS_KEY' => config.fetch(:aws_secret_access_key)
}
command = %(rack install aws \
---name "#{config.fetch(:stack_name)}" \
-"InstanceType=#{config.fetch(:instance_type)}" \
-"BuildInstance=")
+"#{config.fetch(:stack_name)}" \
+"node_type=#{config.fetch(:instance_type)}" \
+"region=#{config.fetch(:aws_region)}")
+ # us-east constantly has problems with the us-east-1c AZ:
+ # "Cannot create cluster 'ds-enterprise-cx3' because us-east-1c, the targeted
+ # availability zone, does not currently have sufficient capacity to support the cluster.
+ # Retry and choose from these availability zones:
+ # us-east-1a, us-east-1b, us-east-1d, us-east-1e, us-east-1f
+ if config.fetch(:aws_region) == 'us-east-1'
+ command += ' "availability_zones=us-east-1a,us-east-1b,us-east-1d,us-east-1e,us-east-1f"'
+ end
- run_convox_command!(command, env)
+ run_convox_command!(command, env, rack_arg: false)
end
def rack_already_installed?
require_config(%i[aws_region stack_name])
return unless File.exist?(AUTH_FILE)
- region = config.fetch(:aws_region)
+ # region = config.fetch(:aws_region)
stack_name = config.fetch(:stack_name)
+ return true if File.exist?(rack_dir)
- auth.each do |host, _password|
- if host.match?(/^#{stack_name}-\d+\.#{region}\.elb\.amazonaws\.com$/)
- return true
- end
+ auth.each do |rack_name, _password|
+ return true if rack_name == stack_name
end
false
end
- def validate_convox_auth_and_write_host!
+ # Auth for a detached rack is not saved in the auth file anymore.
+ # It can be found in the terraform state:
+ # ~/Library/Preferences/convox/racks/ds-enterprise-cx3/terraform.tfstate
+ # Under outputs/api/value. The API URL contains the convox username and API token as basic auth.
+ def validate_convox_rack_and_write_current!
require_config(%i[aws_region stack_name])
- unless File.exist?(AUTH_FILE)
- raise "Could not find auth file at #{AUTH_FILE}!"
+ unless rack_already_installed?
+ raise "Could not find rack terraform directory at: #{rack_dir}"
end
- region = config.fetch(:aws_region)
- stack = config.fetch(:stack_name)
-
- match_count = 0
- matching_host = nil
- auth.each do |host, _password|
- if host.match?(/^#{stack}-\d+\.#{region}\.elb\.amazonaws\.com$/)
- matching_host = host
- match_count += 1
- end
- end
-
- if match_count == 1
- write_host(matching_host)
- return matching_host
- end
-
- error_message = if match_count > 1
- 'Found multiple matching hosts for '
- else
- 'Could not find matching authentication for '
- end
- error_message += "region: #{region}, stack: #{stack}"
- raise error_message
+ # Tells the Convox CLI to use our terraform stack
+ stack_name = config.fetch(:stack_name)
+ write_current(stack_name)
+ stack_name
end
- def write_host(host)
- logger.debug "Setting convox host to #{host} (in #{HOST_FILE})..."
- File.open(HOST_FILE, 'w') { |f| f.puts host }
+ def write_current(rack_name)
+ logger.debug "Setting convox rack to #{rack_name} (in #{CURRENT_FILE})..."
+ current_hash = { name: rack_name, type: 'terraform' }
+ File.open(CURRENT_FILE, 'w') { |f| f.puts current_hash.to_json }
end
- def validate_convox_rack!
+ def validate_convox_rack_api!
require_config(%i[
aws_region
stack_name
instance_type
])
logger.debug 'Validating that convox rack has the correct attributes...'
+ # Convox 3 racks no longer return info about region or type. (These are blank strings.)
{
provider: 'aws',
- region: config.fetch(:aws_region),
- type: config.fetch(:instance_type),
+ # region: config.fetch(:aws_region),
+ # type: config.fetch(:instance_type),
name: config.fetch(:stack_name)
}.each do |k, v|
convox_value = convox_rack_data[k.to_s]
if convox_value != v
raise "Convox data did not match! Expected #{k} to be '#{v}', " \
@@ -182,13 +191,27 @@
end
def convox_rack_data
@convox_rack_data ||= begin
logger.debug 'Fetching convox rack attributes...'
- convox_output = `convox api get /system`
- raise 'convox command failed!' unless $CHILD_STATUS.success?
+ command = "convox api get /system --rack #{config.fetch(:stack_name)}"
+ logger.debug "+ #{command}"
+ # It can take a while for the API to be ready.
+ start_time = Time.now
+ convox_output = nil
+ loop do
+ convox_output = `#{command}`
+ break if $CHILD_STATUS.success?
+ if Time.now - start_time > 360
+ raise 'Could not connect to Convox rack API!'
+ end
+
+ logger.debug 'Waiting for Convox rack API to be ready... (can take a few minutes)'
+ sleep 5
+ end
+
JSON.parse(convox_output)
end
end
def create_convox_app!
@@ -197,13 +220,14 @@
app_name = config.fetch(:convox_app_name)
logger.info "Creating app: #{app_name}..."
logger.info '=> Documentation: ' \
- 'https://docs.convox.com/deployment/creating-an-application'
+ 'https://docs.convox.com/reference/cli/apps/'
- run_convox_command! "apps create #{app_name} --wait"
+ # NOTE: --wait flags were removed in Convox 3. It now waits by default.
+ run_convox_command! "apps create #{app_name}"
retries = 0
loop do
break if convox_app_exists?
@@ -230,11 +254,11 @@
def convox_app_exists?
require_config(%i[convox_app_name])
app_name = config.fetch(:convox_app_name)
logger.debug "Looking for existing #{app_name} app..."
- convox_output = `convox api get /apps`
+ convox_output = `convox api get /apps --rack #{config.fetch(:stack_name)}`
raise 'convox command failed!' unless $CHILD_STATUS.success?
apps = JSON.parse(convox_output)
apps.each do |app|
if app['name'] == app_name
@@ -245,141 +269,130 @@
logger.debug "=> Did not find #{app_name} app."
false
end
# Create the s3 bucket, and also apply a CORS configuration
- def create_s3_bucket!
+ # Convox v3 update - They removed support for S3 resources, so we have to do
+ # in terraform now (which is actually pretty nice!)
+ def add_s3_bucket
require_config(%i[s3_bucket_name])
- bucket_name = config.fetch(:s3_bucket_name)
- if s3_bucket_exists?
- logger.info "#{bucket_name} S3 bucket already exists!"
- else
- logger.info "Creating S3 bucket resource (#{bucket_name})..."
- run_convox_command! 'rack resources create s3 ' \
- "--name \"#{bucket_name}\" " \
- '--wait'
- retries = 0
- loop do
- break if s3_bucket_exists?
-
- if retries > 10
- raise "Something went wrong while creating the #{bucket_name} S3 bucket! " \
- '(Please wait a few moments and then restart the installation script.)'
- end
- logger.debug 'Waiting for S3 bucket to be ready...'
- sleep 3
- retries += 1
- end
-
- logger.debug '=> S3 bucket created!'
+ unless config.key? :s3_bucket_cors_rule
+ logger.debug 'No CORS rule provided in config: s3_bucket_cors_rule (optional)'
+ return
end
- set_s3_bucket_cors_policy
+ write_terraform_template('s3_bucket')
end
- def s3_bucket_exists?
- require_config(%i[s3_bucket_name])
- bucket_name = config.fetch(:s3_bucket_name)
- logger.debug "Looking up S3 bucket resource: #{bucket_name}"
- `convox api get /resources/#{bucket_name} 2>/dev/null`
- $CHILD_STATUS.success?
+ def add_rds_database
+ require_config(%i[database_username database_password])
+ write_terraform_template('rds')
end
- def s3_bucket_details
- require_config(%i[s3_bucket_name])
- @s3_bucket_details ||= begin
- bucket_name = config.fetch(:s3_bucket_name)
- logger.debug "Fetching S3 bucket resource details for #{bucket_name}..."
+ def add_elasticache_cluster
+ write_terraform_template('elasticache')
+ end
- response = `convox api get /resources/#{bucket_name}`
- raise 'convox command failed!' unless $CHILD_STATUS.success?
+ def write_terraform_template(name)
+ template_path = File.join(__dir__, "../../terraform/#{name}.tf.erb")
+ unless File.exist?(template_path)
+ raise "Could not find terraform template at: #{template_path}"
+ end
- bucket_data = JSON.parse(response)
- s3_url = bucket_data['url']
- matches = s3_url.match(
- %r{^s3://(?<access_key_id>[^:]*):(?<secret_access_key>[^@]*)@(?<bucket_name>.*)$}
- )
+ template = ERB.new(File.read(template_path))
+ template_output = template.result(binding)
- match_keys = %i[access_key_id secret_access_key bucket_name]
- unless matches && match_keys.all? { |k| matches[k].present? }
- raise "#{s3_url} is an invalid S3 URL!"
- end
+ tf_file_path = File.join(rack_dir, "#{name}.tf")
+ logger.debug "Writing terraform config to #{tf_file_path}..."
+ File.open(tf_file_path, 'w') { |f| f.puts template_output }
+ end
- {
- access_key_id: matches[:access_key_id],
- secret_access_key: matches[:secret_access_key],
- name: matches[:bucket_name]
- }
+ def apply_terraform_update!
+ logger.info 'Applying terraform update...'
+ command = if ENV['DEBUG_TERRAFORM']
+ 'terraform plan'
+ else
+ 'terraform apply -auto-approve'
+ end
+ logger.debug "+ #{command}"
+
+ env = {
+ 'AWS_ACCESS_KEY_ID' => config.fetch(:aws_access_key_id),
+ 'AWS_SECRET_ACCESS_KEY' => config.fetch(:aws_secret_access_key)
+ }
+ Dir.chdir(rack_dir) do
+ system env, command
+ raise 'terraform command failed!' unless $CHILD_STATUS.success?
end
end
- def set_s3_bucket_cors_policy
- require_config(%i[aws_access_key_id aws_secret_access_key])
- access_key_id = config.fetch(:aws_access_key_id)
- secret_access_key = config.fetch(:aws_secret_access_key)
+ def terraform_state
+ tf_state_file = File.join(rack_dir, 'terraform.tfstate')
+ JSON.parse(File.read(tf_state_file))
+ end
- unless config.key? :s3_bucket_cors_policy
- logger.debug 'No CORS policy provided in config: s3_bucket_cors_policy'
- return
+ def terraform_resource(resource_type, resource_name)
+ resource = terraform_state['resources'].find do |resource|
+ resource['type'] == resource_type && resource['name'] == resource_name
end
- cors_policy_string = config.fetch(:s3_bucket_cors_policy)
+ return resource if resource
- bucket_name = s3_bucket_details[:name]
+ raise "Could not find #{resource_type} resource named #{resource_name} in terraform state!"
+ end
- logger.debug "Looking up existing CORS policy for #{bucket_name}"
- existing_cors_policy_string =
- `AWS_ACCESS_KEY_ID=#{access_key_id} \
- AWS_SECRET_ACCESS_KEY=#{secret_access_key} \
- aws s3api get-bucket-cors --bucket #{bucket_name} 2>/dev/null`
- if $CHILD_STATUS.success? && existing_cors_policy_string.present?
- # Sort all the nested arrays so that the equality operator works
- existing_cors_policy = JSON.parse(existing_cors_policy_string)
- cors_policy_json = JSON.parse(cors_policy_string)
- [existing_cors_policy, cors_policy_json].each do |policy_json|
- next unless policy_json.is_a?(Hash) && policy_json['CORSRules']
+ def s3_bucket_details
+ require_config(%i[s3_bucket_name])
- policy_json['CORSRules'].each do |rule|
- rule['AllowedHeaders']&.sort!
- rule['AllowedMethods']&.sort!
- rule['AllowedOrigins']&.sort!
- end
- end
+ s3_bucket = terraform_resource('aws_s3_bucket', 'docs_s3_bucket')
+ bucket_attributes = s3_bucket['instances'][0]['attributes']
+ access_key = terraform_resource('aws_iam_access_key', 'docspring_user_access_key')
+ key_attributes = access_key['instances'][0]['attributes']
- if existing_cors_policy == cors_policy_json
- logger.debug "=> CORS policy is already up to date for #{bucket_name}."
- return
- end
- end
+ {
+ access_key_id: key_attributes['id'],
+ secret_access_key: key_attributes['secret'],
+ name: bucket_attributes['bucket']
+ }
+ end
- begin
- logger.info "Setting CORS policy for #{bucket_name}..."
+ def rds_details
+ require_config(%i[database_username database_password])
- File.open('cors-policy.json', 'w') { |f| f.puts cors_policy_string }
+ database = terraform_resource('aws_db_instance', 'rds_database')
+ database_attributes = database['instances'][0]['attributes']
- `AWS_ACCESS_KEY_ID=#{access_key_id} \
- AWS_SECRET_ACCESS_KEY=#{secret_access_key} \
- aws s3api put-bucket-cors \
- --bucket #{bucket_name} \
- --cors-configuration "file://cors-policy.json"`
- unless $CHILD_STATUS.success?
- raise 'Something went wrong while setting the S3 bucket CORS policy!'
- end
+ username = database_attributes['username']
+ password = database_attributes['password']
+ endpoint = database_attributes['endpoint']
+ postgres_url = "postgres://#{username}:#{password}@#{endpoint}/app"
+ {
+ postgres_url: postgres_url
+ }
+ end
- logger.info "=> Successfully set CORS policy for #{bucket_name}."
- ensure
- FileUtils.rm_f 'cors-policy.json'
- end
+ def elasticache_details
+ require_config(%i[s3_bucket_name])
+
+ # Just ensure that the bucket exists in the state
+ cluster = terraform_resource('aws_elasticache_cluster', 'elasticache_cluster')
+ cluster_attributes = cluster['instances'][0]['attributes']
+ cache_node = cluster_attributes['cache_nodes'][0]
+ redis_url = "redis://#{cache_node['address']}:#{cache_node['port']}/0"
+
+ {
+ redis_url: redis_url
+ }
end
def add_docker_registry!
require_config(%i[docker_registry_url docker_registry_username docker_registry_password])
registry_url = config.fetch(:docker_registry_url)
logger.debug 'Looking up existing Docker registries...'
- registries_response = `convox api get /registries`
+ registries_response = `convox api get /registries --rack #{config.fetch(:stack_name)}`
unless $CHILD_STATUS.success?
raise 'Something went wrong while fetching the list of registries!'
end
registries = JSON.parse(registries_response)
@@ -389,39 +402,40 @@
return true
end
logger.info "Adding Docker Registry: #{registry_url}..."
logger.info '=> Documentation: ' \
- 'https://docs.convox.com/deployment/private-registries'
+ 'https://docs.convox.com/configuration/private-registries/'
`convox registries add "#{registry_url}" \
"#{config.fetch(:docker_registry_username)}" \
- "#{config.fetch(:docker_registry_password)}"`
+ "#{config.fetch(:docker_registry_password)}" \
+ --rack #{config.fetch(:stack_name)}`
return if $CHILD_STATUS.success?
raise "Something went wrong while adding the #{registry_url} registry!"
end
def default_service_domain_name
- require_config(%i[convox_app_name default_service])
+ require_config(%i[convox_app_name])
- @default_service_domain_name ||= begin
- convox_domain = convox_rack_data['domain']
- elb_name_and_region = convox_domain[/([^.]*\.[^.]*)\..*/, 1]
- unless elb_name_and_region.present?
- raise 'Something went wrong while parsing the ELB name and region! ' \
- "(#{elb_name_and_region})"
- end
- app = config.fetch(:convox_app_name)
- service = config.fetch(:default_service)
+ app_name = config.fetch(:convox_app_name)
+ default_service = config[:default_service] || 'web'
- # Need to return downcase host so that `config.hosts` works with Rails applications
- "#{app}-#{service}.#{elb_name_and_region}.convox.site".downcase
- end
+ convox_api_url = terraform_state['outputs']['api']['value']
+ convox_router_host = convox_api_url.split('@').last.sub(/^api\./, '')
+
+ [default_service, app_name, convox_router_host].join('.').downcase
end
- def run_convox_command!(cmd, env = {})
+ def run_convox_command!(cmd, env = {}, rack_arg: true)
+ # Always include the rack as an argument, to
+ # make sure that 'convox switch' doesn't affect any commands
command = "convox #{cmd}"
+ if rack_arg
+ command = "#{command} --rack #{config.fetch(:stack_name)}"
+ end
+ logger.debug "+ #{command}"
system env, command
raise "Error running: #{command}" unless $CHILD_STATUS.success?
end
private