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