#!/usr/bin/env ruby require 'yaml' require 'bundler' require 'active_support/all' require 'pry' require 'pty' require 'benchmark' require 'colorize' class DeisUtils < Struct.new(:args) TRUTHY_STRINGS = %w(t true y yes 1).flat_map do |str| [str.downcase, str.upcase, str.capitalize] end.uniq FALSEY_STRINGS = %w(f false n no 0).flat_map do |str| [str.downcase, str.upcase, str.capitalize] end.uniq NonZeroExitError = Class.new(StandardError) def run command = args.shift status = Helpers.run_util(command, *args) exit status === false ? 1 : 0 end module Helpers module_function def run_util(name, *args) status = false time = Benchmark.realtime do status = get_runner(name).new(*args).run end STDOUT.puts "#{$0} #{name} #{args.join ' '}` took #{seconds_to_human time}".light_blue if debug? status end module_function def debug? ENV['DEBUG'].in? TRUTHY_STRINGS end module_function def get_runner(name) DeisUtils.const_get(name.gsub('-', '_').camelize) rescue NameError STDERR.puts "command `#{name}` does not exist!".red exit 1 end def app_exists?(app) !!deis_command('info', app: app) rescue NonZeroExitError false end def status(msg) msg = "| #{msg} |" sep = ''.ljust(msg.size, '-') puts "\n\e[0;92;49m#{[sep, msg, sep].join "\n"}\e[0m" end def databases(app) databases = deis_command('pg:info', app: app).split('===') databases[1..-1].map do |db| lines = db.lines name = lines[0].split(' ')[0] data = lines[1..-1].join { 'Name' => name }.merge YAML.load(data) end end def info(app) cmd_result = deis_command(:info, app: app) data = JSON.load cmd_result.match(/Application\r\n(?\{.*\})/m)[:json] controller_host = data['url'].split('.').tap { |p| p[p.index app] = 'deis' }.join('.') data['git_url'] = "ssh://git@#{controller_host}:2222/#{app}.git" data['domains'] = cmd_result.match(/Domains\r\n(?.*)/m)[:domains].lines.map(&:strip).reject(&:empty?) data.sort.to_h end def shell(*commands) flags = commands.last.is_a?(Hash) ? commands.pop : {} command = commands.join ' ' flags.each_with_object(command) do |(k, v), c| c << case v when TrueClass, FalseClass v ? " --#{k}" : '' when String, Fixnum " --#{k} #{v}" else raise ArgumentError end end result = nil time = Benchmark.realtime do result = capture_syscall command end STDOUT.puts "`#{command}` took #{seconds_to_human time}".light_black if debug? result end def deis_command(*cmds) shell :deis, *cmds end def git_clone(url, flags = {}) shell "git clone #{url}", flags end def deploy(url, options = {}) ref = options.delete(:ref) || 'master' shell "git push #{url} #{ref}:master", options end def capture_syscall(command) puts "\n#{command}".light_yellow if debug? String.new.tap do |output| threads = [] PTY.spawn(command) do |p_out, p_in, pid| threads << Thread.new { p_in.close } threads << Thread.new { output << capture_output(p_out) } Process.wait pid threads.each(&:join) end raise NonZeroExitError, output unless $?.exitstatus == 0 end end def capture_output(io) output = '' io.each do |line| output << line STDOUT.puts "#{line.strip}".light_black unless line.nil? || line.strip.empty? if debug? end ensure return output end # Warn Slack for upcoming maintenance mode def warn_for_maintenance(app) Slacker.message(text: "WARNING: #{app} will go into maintenance mode shortly.", username: "Deploy Bot", channel: "#pulse-dev", color: "warning", icon_emoji: ":warning:") end # Enable Maintenance Mode on Heroku def enable_maintenance_mode(app) get_units! status "#{app} is going into maintenance mode!" Slacker.message(text: "ALERT: #{app} is going into maintenance mode!", username: "Deploy Bot", channel: "#pulse-dev", color: "danger", icon_emoji: ":bangbang:") scale app, units.keys.each_with_object({}) { |k, h| h[k] = 0 } end # Disable Maintenance Mode on Heroku def disable_maintenance_mode(app) scale app, units status "#{app} is no longer in maintenance mode!" Slacker.message(text: "NOTICE: #{app} is no longer in maintenance mode.", username: "Deploy Bot", channel: "#pulse-dev", color: "good", icon_emoji: ":white_check_mark:") end def scale(app, opts={}) process_string = opts.map { |k, v| "#{k}=#{v}" }.join ' ' deis_command "scale #{process_string}", app: app end def get_units! unit_string = deis_command('ps', app: app) shell('foreman check').strip.sub(/.*\((.*)\)/, "\\1").split(', ').each do |unit| units[unit] ||= unit_string.lines.select { |l| l.include? "#{unit}." }.count end end def units @units ||= {} end module_function def seconds_to_human(secs) secs = secs.round output = '' seconds = secs % 60 minutes = (secs - seconds) / 60 output << "#{minutes}m" if minutes > 0 output << "#{seconds}s" end end class Info < Struct.new :app include Helpers def run h = info(app) status "`#{app}` Information" output_hash h end def output_hash(hash, indent = 0) hash.each do |k, v| case v when Hash puts (' ' * indent) + k + ':' output_hash(v, indent + 1) when Array puts (' ' * indent) + k + ':', *v.map { |i| (' ' * (indent + 1)) + i.to_s } else puts (' ' * indent) + "#{k}: #{v}" end end end end class Exists < Struct.new :app include Helpers def run app_exists?(app) end end class CopyConfig < Struct.new :source_app, :dest_app include Helpers def run system <<-sh deis config --app #{source_app} --oneline | xargs deis config:set --app #{dest_app} sh end end class Disable < Struct.new :app include Helpers def run status "Disabling App: #{app}" units = shell('foreman check').strip.sub(/.*\((.*)\)/, "\\1").split(', ') scale app, units.each_with_object({}) { |k, h| h[k] = 0 } end end class Enable < Struct.new :app include Helpers def run status "Enabling App: #{app}" get_units! if units.any? { |_, v| v > 0 } status "App Already enabled!" return end scale app, units.keys.each_with_object({}) { |k, h| h[k] = 1 } status "App enabled!" end end # Deploy a deis repo class Deploy < Struct.new(:app, :ref) include Helpers LAST_MIGRATION_CMD = %{bundle exec rake db:migrate:status} MIGRATION_REGEX = /up\s+(?[0-9]{8}[0-9])+/ attr_reader :worker_count attr_reader :web_count def run status "Deploying `#{ref}` to `#{app}` on Deis" precheck_migrations! output = deploy info['git_url'], ref: ref if output.include? 'Another git push is ongoing' sleep 60 # one minute return run_util 'deploy', app, ref end run_migrations! if needs_migrations? rescue NonZeroExitError => e raise e unless e.message.include? 'Another git push is ongoing' remove_instance_variable :@needs_migrations sleep 60 retry end private def info @info ||= super(app) end def run_migrations! # Todo: put the site in a readonly state but NEVER in maintenance mode status 'Running Migrations' deis_command('run rake "db:migrate"', app: app) end def precheck_migrations! needs_migrations? end def needs_migrations? return false if TRUTHY_STRINGS.include? ENV['SKIP_MIGRATIONS'] return true if TRUTHY_STRINGS.include? ENV['FORCE_MIGRATIONS'] @needs_migrations ||= begin status 'Checking Migration Status' (local_migration != remote_migration).tap do |val| if val puts 'Database out of date, Migrations are Required' else puts 'Database is up to date' end end end end def remote_migration @remote_migration ||= sha_from_migration_status deis_command("run #{LAST_MIGRATION_CMD}", app: app) rescue NonZeroExitError 'error' end def local_migration @local_migration ||= sha_from_migration_status shell(LAST_MIGRATION_CMD) rescue NonZeroExitError 'error' end def sha_from_migration_status(result) lines = result.lines.reject { |line| line.include? '**** NO FILE ****' } migration_lines = lines.map(&:strip).select { |line| line =~ MIGRATION_REGEX } Digest::SHA2.hexdigest migration_lines.join end end end Bundler.with_clean_env do DeisUtils.new(ARGV).run end