# frozen_string_literal: true
require “thor” require_relative “../db_configuration” require_relative “../heroku_targets” require_relative “../thor_utils”
class Heroku < Thor
module Configuration class << self def base_asset_url asset_host = get_config_env(target, "ASSET_HOST") "https://#{asset_host}" end def maintenance_mode_env_var "X_HEROKU_RAILS_MAINTENANCE_MODE" end def app_revision_env_var raise "choose between APP_REVISION and COMMIT_HASH" end def before_deploying(_instance, target, version, description: nil) puts "about to deploy #{version} to #{target.name}" puts " #{description}" if description end def after_deploying(_instance, target, version, description: nil) puts "deployed #{version} to #{target.name}" puts " #{description}" if description end def notify_of_deploy_tracking(running_thor_task, release_stage:, revision:, revision_describe:, repository:, target:, target_name:, deploy_ref:) if ENV["BUGSNAG_API_KEY"].present? running_thor_task.notify_bugsnag_of_deploy_tracking(deploy_ref, release_stage, repository, revision, revision_describe, target_name) else puts "can't notify of deploy tracking: env var not present: BUGSNAG_API_KEY" end end def after_sync_down(instance) instance.puts_and_system "rake db:migrate" instance.puts_and_system "rake db:test:prepare" end def after_sync_to(instance, target) instance.puts_and_system %(heroku run rake db:migrate -a #{target.heroku_app}) end end end module Shared def self.exit_on_failure? true end def lookup_heroku_staging(staging_target_name) heroku_targets.staging_targets[staging_target_name] || raise_missing_target(staging_target_name, true) end def lookup_heroku(target_name) heroku_targets.targets[target_name] || raise_missing_target(target_name, false) end def check_deploy_ref(deploy_ref, target) if deploy_ref && deploy_ref[0] == "-" raise Thor::Error, "Invalid deploy ref '#{deploy_ref}'" end deploy_ref || target.deploy_ref end def raise_missing_target(target_name, staging) if staging description = "Staging target_name '#{target_name}'" targets = heroku_targets.staging_targets.keys else description = "Target '#{target_name}'" targets = heroku_targets.targets.keys end msg = "#{description} was not found. Valid targets are #{targets.collect { |t| "'#{t}'" }.join(",")}" raise Thor::Error, msg end def heroku_targets @heroku_targets ||= HerokuRails::HerokuTargets.from_file(File.expand_path("config/heroku_targets.yml")) end def puts_and_system(cmd) puts cmd puts "-------------" system_with_clean_env cmd puts "-------------" end def puts_and_exec(cmd) puts cmd exec_with_clean_env(cmd) end protected def deploy_message(target, deploy_ref_describe) downtime = options[:migrate] ? "👷 There will be a very short maintenance downtime" : "" message = <<-DEPLOY_MESSAGE Deploying #{target.display_name} #{deploy_ref_describe}. #{downtime} (in less than a minute from now). DEPLOY_MESSAGE message.gsub(/(\s|\n)+/, " ") end def system_with_clean_env(cmd) if defined?(Bundler) Bundler.with_clean_env { system cmd } else system cmd end end def exec_with_clean_env(cmd) if defined?(Bundler) Bundler.with_clean_env { `#{cmd}` } else `#{cmd}` end end def maintenance_on(target) puts_and_system "heroku maintenance:on -a #{target.heroku_app}" puts_and_system "heroku config:set #{Heroku::Configuration.maintenance_mode_env_var}=true -a #{target.heroku_app}" end def maintenance_off(target) puts_and_system "heroku maintenance:off -a #{target.heroku_app}" puts_and_system "heroku config:unset #{Heroku::Configuration.maintenance_mode_env_var} -a #{target.heroku_app}" end end include Shared class_option :verbose, type: :boolean, aliases: "v", default: true default_command :help desc "details", "collects and prints some information local and each target" def details puts details = heroku_targets.targets.map { |name, target| next if target.local? print "." [ "Heroku #{name}", "(versions suppressed -- takes too long)" || exec_with_clean_env("heroku run 'rails -v && ruby -v' -a #{target.heroku_app}"), exec_with_clean_env("heroku releases -n 1 -a #{target.heroku_app}").split("\n").last ] } puts details << [ "Local", exec_with_clean_env("rails -v && ruby -v"), exec_with_clean_env("git describe --always") ] details.each do |n, v, d| puts "-" * 80 puts n puts "-" * 80 puts v puts d end end desc "deploy TARGET (REF)", "deploy the latest to TARGET (optionally give a REF like a tag to deploy)" method_option :migrate, default: true, desc: "Run with migrations", type: :boolean method_option :maintenance, default: nil, desc: "Run with migrations", type: :boolean def deploy(target_name, deploy_ref = nil) target = lookup_heroku(target_name) deploy_ref = check_deploy_ref(deploy_ref, target) deploy_ref_description = deploy_ref_describe(deploy_ref) maintenance = options[:maintenance].nil? && options[:migrate] || options[:maintenance] puts "Deploy #{deploy_ref_description} to #{target} with migrate=#{options[:migrate]} maintenance=#{maintenance} " invoke :list_deployed, [target_name, deploy_ref], {} message = deploy_message(target, deploy_ref_description) Configuration.before_deploying(self, target, deploy_ref_description) set_message(target_name, message) puts_and_system "git push -f #{target.git_remote} #{deploy_ref}^{}:master" maintenance_on(target) if maintenance if options[:migrate] puts_and_system "heroku run rake db:migrate -a #{target.heroku_app}" end puts_and_system %{heroku config:set #{Heroku::Configuration.app_revision_env_var}=$(git describe --always #{deploy_ref}) -a #{target.heroku_app}} if maintenance maintenance_off(target) else puts_and_system "heroku restart -a #{target.heroku_app}" end set_message(target_name, nil) Configuration.after_deploying(self, target, deploy_ref_description) deploy_tracking(target_name, deploy_ref) end desc "maintenance ON|OFF", "turn maintenance mode on or off" method_option :target_name, aliases: "a", desc: "Target (app or remote)" def maintenance(on_or_off) target = lookup_heroku(options[:target_name]) case on_or_off.upcase when "ON" maintenance_on(target) when "OFF" maintenance_off(target) else raise Thor::Error, "maintenance must be ON or OFF not #{on_or_off}" end end desc "set_urls TARGET", "set and cache the error and maintenance page urls for TARGET" def set_urls(target_name) target = lookup_heroku(target_name) time = Time.now.strftime("%Y%m%d-%H%M-%S") url_hash = { ERROR_PAGE_URL: "#{Heroku::Configuration.base_asset_url}/platform_error/#{time}", MAINTENANCE_PAGE_URL: "#{Heroku::Configuration.base_asset_url}/platform_maintenance/#{time}" } url_hash.each do |_env, url| puts_and_system "open #{url}" end puts_and_system( "heroku config:set #{url_hash.map { |e, u| "#{e}=#{u}" }.join(" ")} -a #{target.heroku_app}" ) end no_commands do def get_config_env(target, env_var) puts_and_exec("heroku config:get #{env_var} -a #{target.heroku_app}").strip.presence end def deploy_ref_describe(deploy_ref) `git describe #{deploy_ref}`.strip end def notify_bugsnag_of_deploy_tracking(deploy_ref, release_stage, repository, revision, revision_describe, target_name) api_key = ENV["BUGSNAG_API_KEY"] data = %W[ apiKey=#{api_key} releaseStage=#{release_stage} repository=#{repository} revision=#{revision} appVersion=#{revision_describe} ].join("&") if api_key.blank? puts "\n" + ("*" * 80) + "\n" command = "curl -d #{data} http://notify.bugsnag.com/deploy" puts command puts "\n" + ("*" * 80) + "\n" puts "NB: can't notify unless you specify BUGSNAG_API_KEY and rerun" puts " thor heroku:deploy_tracking #{target_name} #{deploy_ref}" else puts_and_system "curl -d \"#{data}\" http://notify.bugsnag.com/deploy" end end end desc "deploy_tracking TARGET (REF)", "set deploy tracking for TARGET and REF (used by deploy)" def deploy_tracking(target_name, deploy_ref = nil) target = lookup_heroku(target_name) deploy_ref = check_deploy_ref(deploy_ref, target) release_stage = target.staging? ? "staging" : "production" revision = `git log -1 #{deploy_ref} --pretty=format:%H` Heroku::Configuration.notify_of_deploy_tracking( self, deploy_ref: deploy_ref, release_stage: target.trackable_release_stage, revision: revision, target: target, target_name: target_name, revision_describe: deploy_ref_describe(deploy_ref), repository: target.repository ) end include HerokuRails::ThorUtils desc "set_message TARGET (MESSAGE)", "set message (no-op by default)" def set_message(target_name, message = nil) # no-op -- define as override end desc "list_deployed TARGET (DEPLOY_REF)", "list what would be deployed to TARGET (optionally specify deploy_ref)" def list_deployed(target_name, deploy_ref = nil) target = lookup_heroku(target_name) deploy_ref = check_deploy_ref(deploy_ref, target) puts "------------------------------" puts " Deploy to #{target}:" puts "------------------------------" system_with_clean_env "git --no-pager log $(heroku config:get #{Heroku::Configuration.app_revision_env_var} -a #{target.heroku_app})..#{deploy_ref}" puts "------------------------------" end desc "about (TARGET)", "Describe available targets or one specific target" def about(target_name = nil) if target_name.nil? puts "Targets: " heroku_targets.targets.each_pair do |key, target| puts " * #{key} (#{target})" end else target = lookup_heroku(target_name) puts "Target #{target_name}:" puts " * display_name: #{target.display_name}" puts " * heroku_app: #{target.heroku_app}" puts " * git_remote: #{target.git_remote}" puts " * deploy_ref: #{target.deploy_ref}" end puts puts "(defined in config/heroku_targets.yml)" end class Sync < Thor include Shared class_option :from, type: :string, desc: "source target (production, staging...)", required: true, aliases: "f" desc "down --from SOURCE_TARGET", "syncs db down from SOURCE_TARGET | thor heroku:sync -f production" def down invoke "grab", [], from: options[:from] invoke "from_dump", [], from: options[:from] end desc "warn", "warn", hide: true def warn puts "should maybe 'rake db:drop_all_tables' first" puts "if you have done some table-creating migrations that need tobe undone???" end desc "grab --from SOURCE_TARGET", "capture and download dump from SOURCE_TARGET", hide: true def grab source = lookup_heroku(options[:from]) capture_cmd = "heroku pg:backups:capture -a #{source.heroku_app}" puts_and_system capture_cmd invoke "download", [], from: options[:from] end desc "download --from SOURCE_TARGET", "download latest db snapshot on source_target" def download source = lookup_heroku(options[:from]) download_cmd = "curl -o #{source.dump_filename} `heroku pg:backups:public-url -a #{source.heroku_app}`" puts_and_system download_cmd end desc "from_dump --from SOURCE_TARGET", "make the db the same as the last target dump from SOURCE_TARGET" method_option :just_restore, default: false, desc: "Just do restore without post-actions", type: :boolean def from_dump invoke "warn", [], from: options[:from] source = lookup_heroku(options[:from]) rails_env = ENV["RAILS_ENV"] || "development" db_config = HerokuRails::DbConfiguration.new.config[rails_env] db_username = db_config["username"] db = db_config["database"] db_username_params = db_username.blank? && "" || "-U #{db_username}" puts_and_system "pg_restore --verbose --clean --no-acl --no-owner -h localhost #{db_username_params} -d #{db} #{source.dump_filename}" Configuration.after_sync_down(self) unless options[:just_restore] end desc "dump_to_tmp", "dump to tmp directory" method_option(:from, type: :string, default: "local", desc: "heroku target (defaults to local)", required: false, aliases: "f") def dump_to_tmp source = lookup_heroku(options[:from]) dump_local(source.dump_filename) end desc "to STAGING_TARGET --from=SOURCE_TARGET", "push db onto STAGING_TARGET from SOURCE_TARGET" def to(to_target_name) target = lookup_heroku_staging(to_target_name) source = lookup_heroku(options[:from]) maintenance_on(target) puts_and_system %( heroku pg:copy #{source.heroku_app}::#{source.db_color} #{target.db_color} -a #{target.heroku_app} --confirm #{target.heroku_app} ) Configuration.after_sync_to(self, target) unless options[:just_copy] puts_and_system %(heroku restart -a #{target.heroku_app}) maintenance_off(target) end private def dump_local(dumpfilepath) puts "dumping postgres to #{dumpfilepath}" rails_env = ENV["RAILS_ENV"] || "development" db_config = HerokuRails::DbConfiguration.new.config[rails_env] db_username = db_config["username"] db = db_config["database"] system_with_clean_env "pg_dump --verbose --clean --no-acl --no-owner -h localhost -U #{db_username} --format=c #{db} > #{dumpfilepath}" end end class Db < Thor include Shared desc "drop_all_tables on STAGING_TARGET", "drop all tables on STAGING_TARGET" def drop_all_tables(staging_target_name) target = lookup_heroku_staging(staging_target_name) generate_drop_tables_sql = `#{HerokuRails::DbConfiguration.new.generate_drop_tables_sql}` cmd_string = %(heroku pg:psql -a #{target.heroku_app} -c "#{generate_drop_tables_sql}") puts_and_system(cmd_string) end desc "anonymize STAGING_TARGET", "run anonymization scripts on STAGING_TARGET" def anonymize(staging_target_name) target = lookup_heroku_staging(staging_target_name) puts_and_system %( heroku run rake data:anonymize -a #{target.heroku_app} ) end end
end