heroku-rails-saas 0.1.7 → 1.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,25 @@
1
+ require File.expand_path('../lib/heroku-rails-saas/version', __FILE__)
2
+
3
+ Gem::Specification.new do |gem|
4
+ gem.name = "heroku-rails-saas"
5
+ gem.version = HerokuRailsSaas::VERSION
6
+ gem.authors = ["Elijah Miller", "Glenn Roberts", "Jacques Crocker", "Lance Sanchez", "Chris Trinh"]
7
+ gem.summary = "Deployment and configuration tools for Heroku/Rails"
8
+ gem.description = "Manage multiple Heroku instances/apps for a single Rails app using Rake."
9
+ gem.email = "lance.sanchez@gmail.com"
10
+ gem.homepage = "http://github.com/darkbushido/heroku-rails-saas"
11
+ gem.rubyforge_project = "none"
12
+ gem.require_paths = ["lib"]
13
+ gem.files = `git ls-files | grep -Ev '^(myapp|examples)'`.split("\n")
14
+ gem.test_files = `git ls-files -- spec/*`.split("\n")
15
+ gem.rdoc_options = ["--charset=UTF-8"]
16
+ gem.extra_rdoc_files = ["LICENSE", "README.md", "TODO", "CHANGELOG"]
17
+
18
+ gem.add_runtime_dependency "rails"
19
+ gem.add_runtime_dependency "heroku-api", "~> 0.3.8"
20
+ gem.add_runtime_dependency "netrc", "~> 0.7.7"
21
+ gem.add_runtime_dependency "parallel", "~> 0.6.2"
22
+ gem.add_runtime_dependency "rendezvous", "~> 0.0.2"
23
+ gem.add_development_dependency "rspec", "~> 2.0"
24
+ gem.add_development_dependency "webmock", "~> 1.11.0"
25
+ end
@@ -2,32 +2,33 @@
2
2
  # ### e.g. rake deploy (instead of rake heroku:deploy)
3
3
  # ###
4
4
  # task :deploy => ["heroku:deploy"]
5
- # task :console => ["heroku:console"]
6
5
  # task :setup => ["heroku:setup"]
7
6
  # task :logs => ["heroku:logs"]
8
7
  # task :restart => ["heroku:restart"]
9
8
 
10
9
  # Heroku Deploy Callbacks
11
10
  namespace :heroku do
12
-
13
- # runs before all the deploys complete
11
+ # Runs before all the deploys complete.
14
12
  task :before_deploy do
15
-
16
13
  end
17
14
 
18
- # runs before each push to a particular heroku deploy environment
19
- task :before_each_deploy, [:app_name] do |t,args|
20
-
15
+ # Runs before each push to a particular heroku deploy environment.
16
+ task :before_each_deploy, [:local_name, :remote_name, :configs] do |t, args|
21
17
  end
22
18
 
23
- # runs after each push to a particular heroku deploy environment
24
- task :after_each_deploy, [:app_name] do |t,args|
19
+ # Runs every time there is heroku deploy regardless of exceptions/failures.
20
+ task :ensure_each_deploy, [:local_name, :remote_name, :configs] do |t, args|
21
+ end
25
22
 
23
+ # Runs after each push to a particular heroku deploy environment
24
+ task :after_each_deploy, [:local_name, :remote_name, :configs] do |t, args|
26
25
  end
27
26
 
28
- # runs after all the deploys complete
27
+ # Runs after all the deploys complete
29
28
  task :after_deploy do
30
-
31
29
  end
32
30
 
33
- end
31
+ # Callback for when we switch environment
32
+ task :switch_environment do
33
+ end
34
+ end
@@ -1,4 +1 @@
1
- require 'heroku-rails-saas/config'
2
- require 'heroku-rails-saas/runner'
3
- require 'heroku-rails-saas/railtie' if defined?(::Rails::Railtie)
4
- require 'heroku-rails-saas/hash_recursive_merge'
1
+ require 'heroku-rails-saas/railtie' if defined?(::Rails::Railtie)
@@ -1,9 +1,9 @@
1
1
  require 'active_support/core_ext/object/try'
2
+ require 'active_support/core_ext/hash/deep_merge'
2
3
  require 'erb'
3
4
 
4
5
  module HerokuRailsSaas
5
6
  class Config
6
-
7
7
  SEPERATOR = ":"
8
8
 
9
9
  class << self
@@ -15,7 +15,7 @@ module HerokuRailsSaas
15
15
  @heroku_rails_root = root
16
16
  end
17
17
 
18
- def app_name(app, env)
18
+ def local_name(app, env)
19
19
  "#{app}#{SEPERATOR}#{env}"
20
20
  end
21
21
 
@@ -27,8 +27,7 @@ module HerokuRailsSaas
27
27
  def extract_name_from(app_env)
28
28
  name, env = app_env.split(SEPERATOR)
29
29
  name
30
- end
31
-
30
+ end
32
31
  end
33
32
 
34
33
  attr_accessor :settings
@@ -44,22 +43,10 @@ module HerokuRailsSaas
44
43
  def app_names
45
44
  apps.keys
46
45
  end
47
-
48
- def cmd(app_env)
49
- if self.stack(app_env) =~ /cedar/i
50
- 'heroku run '
51
- else
52
- 'heroku '
53
- end
54
- end
55
-
56
- def rails_cli script
57
- Rails::VERSION::MAJOR < 3 ? ".script/#{script}" : "rails #{script}"
58
- end
59
46
 
60
- # Returns the app name on heroku froma string format like so: `app:env`
47
+ # Returns the app name on heroku from a string format like so: `app:env`
61
48
  # Allows for `rake <app:env> [<app:env>] <command>`
62
- def app_name_on_heroku(string)
49
+ def heroku_app_name(string)
63
50
  app_name, env = string.split(SEPERATOR)
64
51
  apps[app_name][env]
65
52
  end
@@ -67,7 +54,9 @@ module HerokuRailsSaas
67
54
  # return all enviromnets in this format app:env
68
55
  def app_environments(env_filter="")
69
56
  apps.each_with_object([]) do |(app, hsh), arr|
70
- hsh.each { |env, app_name| arr << self.class.app_name(app, env) if (env_filter.nil? || env_filter.empty?) || env == env_filter }
57
+ hsh.each do |env, app_name|
58
+ arr << self.class.local_name(app, env) if(env_filter.nil? || env_filter.empty? || env == env_filter)
59
+ end
71
60
  end
72
61
  end
73
62
 
@@ -103,7 +92,6 @@ module HerokuRailsSaas
103
92
  app_setting_hash("scale", app_env)
104
93
  end
105
94
 
106
-
107
95
  # return a list of collaborators for a particular app environment
108
96
  def collaborators(app_env)
109
97
  app_setting_array('collaborators', app_env)
@@ -111,18 +99,33 @@ module HerokuRailsSaas
111
99
 
112
100
  # return a list of addons for a particular app environment
113
101
  def addons(app_env)
114
- app_setting_array('addons', app_env)
102
+ all_addons = app_setting_array('addons', app_env)
103
+
104
+ # Replace default addons tier with app specific ones.
105
+ addons = all_addons.each_with_object({}) do |addon, hash|
106
+ name, tier = addon.split(":")
107
+ hash[name] = tier
108
+ end
109
+
110
+ addons.to_a.map { |key_value| key_value.join(":") }
115
111
  end
116
112
 
117
- private
113
+ # return the region for a particular app environment
114
+ def region(app_env)
115
+ name, env = app_env.split(SEPERATOR)
116
+ stacks = self.settings['region'] || {}
117
+ stacks[name].try("[]", env) || stacks['all']
118
+ end
118
119
 
120
+ private
119
121
  # Add app specific settings to the default ones defined in all for an array listing
120
122
  def app_setting_array(setting_key, app_env)
121
123
  name, env = app_env.split(SEPERATOR)
122
124
  setting = self.settings[setting_key] || {}
123
125
  default = setting['all'] || []
124
126
 
125
- app_settings = setting[name].try("[]", env) || []
127
+ app_settings = Array.wrap(setting[name].try("[]", env))
128
+
126
129
  (default + app_settings).uniq
127
130
  end
128
131
 
@@ -159,9 +162,9 @@ module HerokuRailsSaas
159
162
  end
160
163
 
161
164
  def aggregate_heroku_configs(config_files)
162
- hsh = config_files[:apps].each_with_object({}) { |file, h| h.rmerge!(parse_yml(file, :apps)) }
165
+ configs = config_files[:apps].each_with_object({}) { |file, h| h.deep_merge!(parse_yml(file, :apps)) }
163
166
  # overwrite all configs with the environment specific ones
164
- hsh.rmerge!(parse_yml(config_files[:default], :default))
167
+ configs.deep_merge!(parse_yml(config_files[:default], :default))
165
168
  end
166
169
  end
167
170
  end
@@ -0,0 +1,26 @@
1
+ require_relative 'helper'
2
+
3
+ module HerokuRailsSaas
4
+ class Displayer
5
+ class << self
6
+ # Prepends a string output with a label consisting of the app name and a color code.
7
+ def labelize(message="", new_line=true, remote_name, color)
8
+ message = "[ #{Helper.send(color, remote_name)} ] #{message}"
9
+ message = message + "\n" if new_line && message[-1] != "\n"
10
+ $stdout.print(message)
11
+ $stdout.flush
12
+ end
13
+ end
14
+
15
+ def initialize(remote_name, color)
16
+ @remote_name = remote_name
17
+ @color = color
18
+ end
19
+
20
+ attr_reader :color, :remote_name
21
+
22
+ def labelize(message="", new_line=true)
23
+ self.class.labelize(message, new_line, @remote_name, @color)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,67 @@
1
+ require_relative 'displayer'
2
+
3
+ module HerokuRailsSaas
4
+ module Helper
5
+ COLORS = %w(cyan yellow green magenta red)
6
+ COLOR_CODES = {
7
+ "red" => 31,
8
+ "green" => 32,
9
+ "yellow" => 33,
10
+ "magenta" => 35,
11
+ "cyan" => 36,
12
+ }
13
+
14
+ class << self
15
+ COLORS.each do |color|
16
+ define_method(color.to_sym) { |string| colorize(string, COLOR_CODES[color]) }
17
+ end
18
+
19
+ # Implementation from https://github.com/heroku/heroku/blob/master/lib/heroku/helpers.rb for consistency.
20
+ @@kb = 1024
21
+ @@mb = 1024 * @@kb
22
+ @@gb = 1024 * @@mb
23
+ def format_bytes(amount)
24
+ amount = amount.to_i
25
+ return '(empty)' if amount == 0
26
+ return amount if amount < @@kb
27
+ return "#{(amount / @@kb).round}k" if amount < @@mb
28
+ return "#{(amount / @@mb).round}M" if amount < @@gb
29
+ return "#{(amount / @@gb).round}G"
30
+ end
31
+
32
+ # Implementation from https://github.com/heroku/heroku/blob/master/lib/heroku/helpers.rb for consistency.
33
+ def styled_hash(hash, displayer)
34
+ max_key_length = hash.keys.map {|key| key.to_s.length}.max + 2
35
+ keys ||= hash.keys.sort {|x,y| x.to_s <=> y.to_s}
36
+ keys.each do |key|
37
+ case value = hash[key]
38
+ when Array
39
+ if value.empty?
40
+ next
41
+ else
42
+ elements = value.sort {|x,y| x.to_s <=> y.to_s}
43
+ displayer.labelize("#{key}: ".ljust(max_key_length), false)
44
+ puts elements[0]
45
+ elements[1..-1].each do |element|
46
+ displayer.labelize("#{' ' * max_key_length}#{element}")
47
+ end
48
+ if elements.length > 1
49
+ displayer.labelize
50
+ end
51
+ end
52
+ when nil
53
+ next
54
+ else
55
+ displayer.labelize("#{key}: ".ljust(max_key_length), false)
56
+ puts value
57
+ end
58
+ end
59
+ end
60
+
61
+ private
62
+ def colorize(string, color_code)
63
+ "\e[#{color_code}m#{string}\e[0m"
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,54 @@
1
+ require 'heroku-api'
2
+ require 'netrc'
3
+ require 'json'
4
+
5
+ require_relative 'helper'
6
+
7
+ module HerokuRailsSaas
8
+ class HerokuClient
9
+ HEROKU_API_HOST = "api.heroku.com"
10
+
11
+ def initialize
12
+ @user, @api_token = netrc[HEROKU_API_HOST]
13
+ @heroku = Heroku::API.new(:api_key => @api_token)
14
+ end
15
+
16
+ attr_accessor :user, :heroku, :api_token
17
+
18
+ private
19
+ # Redirects method calls to the Heroku::API client, parse the JSON and returns the body.
20
+ def method_missing(method_name, *args, &block)
21
+ begin
22
+ response = @heroku.__send__(method_name.to_sym, *args, &block)
23
+ response.body
24
+ rescue Heroku::API::Errors::ErrorWithResponse => error
25
+ message = error.response.status == 404 ? "#{Helper.yellow(args[0])} does not exists" : JSON.parse(error.response.body)["error"]
26
+ status = error.response.headers["Status"]
27
+
28
+ raise <<-OUTPUT
29
+ #{Helper.red(error.class)}:
30
+ Status: #{status}
31
+ Message: #{message}
32
+ OUTPUT
33
+ end
34
+ end
35
+
36
+ def netrc # :nodoc:
37
+ @netrc ||= begin
38
+ File.exists?(netrc_path) ? Netrc.read(netrc_path) : raise(StandardError)
39
+ rescue => error
40
+ raise ".netrc missing or no entry found. Try `heroku auth:login`"
41
+ end
42
+ end
43
+
44
+ def netrc_path # :nodoc:
45
+ default = Netrc.default_path
46
+ encrypted = default + ".gpg"
47
+ if File.exists?(encrypted)
48
+ encrypted
49
+ else
50
+ default
51
+ end
52
+ end
53
+ end
54
+ end
@@ -1,8 +1,14 @@
1
+ require_relative 'config'
2
+ require_relative 'runner'
3
+
1
4
  module HerokuRailsSaas
2
5
  class Railtie < ::Rails::Railtie
3
6
  rake_tasks do
4
7
  HerokuRailsSaas::Config.root = ::Rails.root
5
- load 'heroku/rails/tasks.rb'
8
+ if ::Rails.env.development?
9
+ puts "Load heroku-rails-saas rake task"
10
+ load 'heroku/rails/tasks.rb'
11
+ end
6
12
  end
7
13
  end
8
- end
14
+ end
@@ -1,247 +1,531 @@
1
1
  require 'active_support/core_ext/object/blank'
2
- require 'heroku/client'
2
+ require 'parallel'
3
+
4
+ require_relative 'heroku_client'
5
+ require_relative 'helper'
6
+ require_relative 'displayer'
3
7
 
4
8
  module HerokuRailsSaas
5
9
  class Runner
10
+ extend Forwardable
11
+
12
+ DATABASE_REGEX = /heroku-postgresql|shared-database|heroku-shared-postgresql|amazon_rds/
13
+ DEFAULT_HEROKU_DOMAIN = /\.herokuapp\.com\Z/
14
+ SHARED_DATABASE_ADDON = "shared-database:5mb"
15
+ CONFIG_DELETE_MARKER = "DELETE"
16
+ LOCAL_CA_FILE = File.expand_path('../../data/cacert.pem', __FILE__)
17
+
18
+ class << self
19
+ # Returns an array of :add and :delete deltas respectively.
20
+ def deltas(local, remote)
21
+ [local - remote, remote - local]
22
+ end
23
+ end
24
+
6
25
  def initialize(config)
7
26
  @config = config
8
- @environments = []
27
+ @local_names = []
28
+ @displayer = nil
29
+ @assigned_colors = {}
9
30
  end
10
31
 
11
- def authorize
12
- @heroku ||= Heroku::Client.new(*Heroku::Auth.get_credentials)
32
+ def_delegator :@displayer, :labelize
33
+
34
+ # App/Environment methods
35
+ #---------------------------------------------------------------------------------------------------------------------
36
+ #
37
+ def heroku
38
+ @heroku ||= HerokuClient.new
39
+ end
40
+
41
+ def add_app(local_name)
42
+ @local_names << local_name
13
43
  end
14
44
 
15
- # add a specific environment to the run list
16
- def add_environment(env)
17
- @environments << env
45
+ def add_environment(environment)
46
+ @local_names = @config.app_environments(environment)
18
47
  end
19
48
 
20
- # use all environments or filter out production environments
49
+ # Set filter to true to filter out all production environments.
21
50
  def all_environments(filter=false)
22
- @environments = @config.app_environments
23
- filter ? @environments.reject! { |app| app[regex_for(:production)] } : @environments
51
+ @local_names = @config.app_environments
52
+ filter ? @local_names.reject! { |local_name| local_name[regex_for(:production)] } : @local_names
24
53
  end
25
54
 
26
- # use all heroku apps filtered by environments
27
- def environments(env)
28
- @environments = @config.app_environments(env)
55
+ # Setup methods
56
+ #---------------------------------------------------------------------------------------------------------------------
57
+ #
58
+ def setup_app
59
+ each_heroku_app do |local_name, remote_name|
60
+ _setup_app(local_name, remote_name)
61
+ end
29
62
  end
30
63
 
31
- # setup apps (create if necessary)
32
- def setup_apps
33
- authorize unless @heroku
64
+ def setup_stack
65
+ each_heroku_app do |local_name, remote_name|
66
+ remote_stack = heroku.get_stack(remote_name).select { |stack| stack["current"] }.first["name"]
67
+ local_stack = @config.stack(local_name)
34
68
 
35
- # get a list of all my current apps on Heroku (so we don't create dupes)
36
- @my_apps = @heroku.list.map{|a| a.first}
69
+ if local_stack != remote_stack
70
+ puts "Migrating the app: #{remote_name} to the stack: #{local_stack}"
71
+ heroku.put_stack(remote_name, local_stack)
72
+ end
73
+ end
74
+ end
75
+
76
+ def setup_collaborators
77
+ each_heroku_app do |local_name, remote_name|
78
+ _setup_collaborators(local_name, remote_name)
79
+ end
80
+ end
37
81
 
38
- each_heroku_app do |heroku_env, app_name, repo|
39
- next if @my_apps.include?(app_name)
82
+ def setup_addons
83
+ each_heroku_app do |local_name, remote_name|
84
+ _setup_addons(local_name, remote_name)
85
+ end
86
+ end
40
87
 
41
- options = { :remote => app_name, :stack => @config.stack(heroku_env) }
88
+ def setup_config
89
+ each_heroku_app do |local_name, remote_name|
90
+ _setup_config(local_name, remote_name)
91
+ end
92
+ end
42
93
 
43
- @heroku.create(app_name, options)
94
+ def setup_domains
95
+ each_heroku_app do |local_name, remote_name|
96
+ _setup_domains(local_name, remote_name)
44
97
  end
45
98
  end
46
99
 
47
- # setup the stacks for each app (migrating if necessary)
48
- def setup_stacks
49
- authorize unless @heroku
50
- each_heroku_app do |heroku_env, app_name, repo|
51
- # get the intended stack setting
52
- stack = @config.stack(heroku_env)
100
+ # Action methods
101
+ #---------------------------------------------------------------------------------------------------------------------
102
+ #
103
+ def deploy
104
+ require 'pty'
105
+
106
+ Rake::Task["heroku:before_deploy"].invoke
107
+
108
+ deploy_branch = prompt_for_branch
53
109
 
54
- # get the remote info about the app from heroku
55
- heroku_app_info = @heroku.info(app_name) || {}
110
+ each_heroku_app do |local_name, remote_name|
111
+ @displayer.labelize("Deploying to #{remote_name}...")
112
+
113
+ configs = @config.config(local_name)
114
+
115
+ Rake::Task["heroku:before_each_deploy"].reenable
116
+ Rake::Task["heroku:before_each_deploy"].invoke(local_name, remote_name, configs)
117
+
118
+ begin
119
+ raise "Server not setup run `rake #{local_name} heorku:setup`" if _setup_app?(remote_name)
120
+
121
+ repo = heroku.get_app(remote_name)["git_url"]
122
+
123
+ # The use of PTY here is because we can't depend on the external process 'git' to flush its
124
+ # buffered output to STDOUT, using PTY we can mimic a terminal and trick 'git' into periodically flushing
125
+ # it's output.
126
+ # NOTE: The process bar in 'git' doesn't render correct since it tries to re-render the same line while
127
+ # other proceess are trying to do the same.
128
+ # ^0 is required so git dereferences the tag into a commit SHA (else Heroku's git server will throw up)
129
+ # See https://github.com/TerraCycleUS/heroku-rails-saas/commit/25cbcd3d79fe74e4e54297df1022a39bdd104668
130
+ PTY.spawn("git push #{repo} --force #{deploy_branch}^0:refs/heads/master") do |read_io, _, pid|
131
+ begin
132
+ read_io.sync = true
133
+ read_io.each { |line| @displayer.labelize(line) }
134
+ Process.wait(pid)
135
+ rescue Errno::EIO
136
+ end
137
+ end
138
+
139
+ if continue = $?.exitstatus
140
+ _maintenance(true, remote_name)
141
+ _setup_collaborators(local_name, remote_name)
142
+ _setup_addons(local_name, remote_name)
143
+ _setup_domains(local_name, remote_name)
144
+ _setup_config(local_name, remote_name)
145
+ _migrate(remote_name)
146
+ _scale(local_name, remote_name)
147
+ _restart(remote_name)
148
+ _maintenance(false, remote_name)
149
+ end
150
+ rescue Interrupt
151
+ @displayer.labelize("!!! Interrupt issued stopping deployment")
152
+ rescue Exception => error
153
+ @displayer.labelize("!!! Error deploying: #{Helper.red(error.message)}")
154
+ continue = false
155
+ ensure
156
+ Rake::Task["heroku:ensure_each_deploy"].reenable
157
+ Rake::Task["heroku:ensure_each_deploy"].invoke(local_name, remote_name, configs)
158
+ end
56
159
 
57
- # if the stacks don't match, then perform a migration
58
- if stack != heroku_app_info[:stack]
59
- puts "Migrating the app: #{app_name} to the stack: #{stack}"
60
- creation_command "heroku stack:migrate #{stack} --app #{app_name}"
160
+ if continue
161
+ Rake::Task["heroku:after_each_deploy"].reenable
162
+ Rake::Task["heroku:after_each_deploy"].invoke(local_name, remote_name, configs)
61
163
  end
62
164
  end
165
+
166
+ Rake::Task["heroku:after_deploy"].invoke
63
167
  end
64
168
 
65
- # setup the list of collaborators
66
- def setup_collaborators
67
- authorize unless @heroku
68
- each_heroku_app do |heroku_env, app_name, repo|
69
- # get the remote info about the app from heroku
70
- heroku_app_info = @heroku.info(app_name) || {}
71
-
72
- # get the intended list of collaborators to add
73
- collaborator_emails = @config.collaborators(heroku_env)
74
-
75
- # add current user to collaborator list (always)
76
- collaborator_emails << @heroku.user unless collaborator_emails.include?(@heroku.user)
77
- collaborator_emails << heroku_app_info[:owner] unless collaborator_emails.include?(heroku_app_info[:owner])
78
-
79
- # get existing collaborators
80
- existing_emails = heroku_app_info[:collaborators].to_a.map{|c| c[:email]}
81
-
82
- # get the list of collaborators to delete
83
- existing_emails.each do |existing_email|
84
- # check to see if we need to delete this person
85
- unless collaborator_emails.include?(existing_email)
86
- # delete that collaborator if they arent on the approved list
87
- destroy_command "heroku sharing:remove #{existing_email} --app #{app_name}"
88
- end
89
- end
169
+ def exec_on_all(command)
170
+ each_heroku_app do |_, remote_name|
171
+ async_exec(remote_name, command)
172
+ end
173
+ end
174
+
175
+ # Triggers an one-off process.
176
+ def async_exec(remote_name, command)
177
+ result = heroku.post_ps(remote_name, command)
178
+ @displayer.labelize("[async] " + Helper.green(result["command"]))
179
+ end
180
+
181
+ # Triggers an one-off process and waits for the job to complete.
182
+ def sync_exec(remote_name, command)
183
+ result = heroku.post_ps(remote_name, command)
184
+ process_id = result["id"]
185
+ @displayer.labelize("[sync] " + Helper.green(result["command"]))
186
+ begin
187
+ sleep 1
188
+ running_process_ids = heroku.get_ps(remote_name).map { |ps| ps["id"] }
189
+ end while running_process_ids.include?(process_id)
190
+ end
191
+
192
+ def apps
193
+ each_heroku_app do |local_name, remote_name|
194
+ repo = heroku.get_app(remote_name)["git_url"]
195
+ puts "#{Helper.red(local_name)} maps to the Heroku app #{Helper.yellow(remote_name)}:"
196
+ puts " #{Helper.green(repo)}"
197
+ puts
198
+ end
199
+ end
90
200
 
91
- # get the list of collaborators to add
92
- collaborator_emails.each do |collaborator_email|
93
- # check to see if we need to add this person
94
- unless existing_emails.include?(collaborator_email)
95
- # add the collaborator if they are not already on the server
96
- creation_command "heroku sharing:add #{collaborator_email} --app #{app_name}"
201
+ # Implementation from https://raw.github.com/heroku/heroku/master/lib/heroku/command/apps.rb to be consistent with our output.
202
+ # See Helper#styled_hash for further implemention details.
203
+ def info
204
+ each_heroku_app do |_, remote_name|
205
+ app_data = heroku.get_app(remote_name)
206
+
207
+ addons = heroku.get_addons(remote_name).map { |addon| addon['name'] }.sort
208
+ collaborators = heroku.get_collaborators(remote_name).map { |collaborator| collaborator['email'] }.sort
209
+ collaborators.reject! { |email| email == app_data['owner_email'] }
210
+
211
+ data = {:name => remote_name}
212
+ data["Addons"] = addons if addons.present?
213
+ data["Collaborators"] = collaborators
214
+ data["Create Status"] = app_data["create_status"] if app_data["create_status"] && app_data["create_status"] != "complete"
215
+ data["Database Size"] = Helper.format_bytes(app_data["database_size"]) if app_data["database_size"]
216
+ data["Git URL"] = app_data["git_url"]
217
+ data["Database Size"].gsub!('(empty)', '0K') + " in #{quantify("table", app_data["database_tables"])}" if app_data["database_tables"]
218
+
219
+ if app_data["dyno_hours"].is_a?(Hash)
220
+ data["Dyno Hours"] = app_data["dyno_hours"].keys.map do |type|
221
+ "%s - %0.2f dyno-hours" % [ type.to_s.capitalize, app_data["dyno_hours"][type] ]
97
222
  end
98
223
  end
99
224
 
100
- # display the destructive commands
101
- output_destroy_commands(app_name)
225
+ data["Owner Email"] = app_data["owner_email"]
226
+ data["Region"] = app_data["region"] if app_data["region"]
227
+ data["Repo Size"] = Helper.format_bytes(app_data["repo_size"]) if app_data["repo_size"]
228
+ data["Slug Size"] = Helper.format_bytes(app_data["slug_size"]) if app_data["slug_size"]
229
+ data["Stack"] = app_data["stack"]
230
+ data.merge!("Dynos" => app_data["dynos"], "Workers" => app_data["workers"]) if data["Stack"] != "cedar"
231
+ data["Web URL"] = app_data["web_url"]
232
+ data["Tier"] = app_data["tier"].capitalize if app_data["tier"]
233
+
234
+ Helper.styled_hash(data, @displayer)
235
+ end
236
+ end
237
+
238
+ def maintenance(toggle)
239
+ each_heroku_app do |_, remote_name|
240
+ _maintenance(toggle, remote_name)
241
+ end
242
+ end
243
+
244
+ def restart
245
+ each_heroku_app do |_, remote_name|
246
+ _restart(remote_name)
102
247
  end
103
248
  end
104
249
 
105
- # setup configuration
106
- def setup_config
107
- authorize unless @heroku
108
- each_heroku_app do |app_env, app_name, repo|
109
- # get the configuration that we are aiming towards
110
- new_config = @config.config(app_env)
111
-
112
- # default RACK_ENV and RAILS_ENV to the heroku_env (unless its manually set to something else)
113
- new_config["RACK_ENV"] = HerokuRailsSaas::Config.extract_environment_from(app_env) unless new_config["RACK_ENV"]
114
- new_config["RAILS_ENV"] = HerokuRailsSaas::Config.extract_environment_from(app_env) unless new_config["RAILS_ENV"]
115
- # get the existing config from heroku's servers
116
- existing_config = @heroku.config_vars(app_name) || {}
117
-
118
- # find the config variables to add
119
- add_config = {}
120
- new_config.each do |new_key, new_val|
121
- add_config[new_key] = new_val unless existing_config[new_key] == new_val
122
- end
250
+ def scale
251
+ each_heroku_app do |local_name, remote_name|
252
+ _scale(local_name, remote_name)
253
+ end
254
+ end
123
255
 
124
- # persist the changes onto heroku
125
- unless add_config.empty?
126
- # add the config
127
- set_config = ""
128
- add_config.each do |key, val|
129
- set_config << "#{key}='#{val}' "
130
- end
131
- creation_command "heroku config:add #{set_config} --app #{app_name}"
256
+ def logs
257
+ each_heroku_app do |_, remote_name|
258
+ _logs(remote_name)
259
+ end
260
+ end
261
+
262
+ # NOTE: This doesn't work with more than one environment. My guess is that each process triggers STDIN to flush
263
+ # its buffer causing it to act very strange. A possible solution is to have a master process (or the current rake
264
+ # process) to control the flow of input data via an IO pipe.
265
+ def console
266
+ require 'rendezvous'
267
+
268
+ each_heroku_app do |_, remote_name|
269
+ _console(remote_name)
270
+ end
271
+ end
272
+
273
+ # Helper methods
274
+ #---------------------------------------------------------------------------------------------------------------------
275
+ #
276
+ # Cycles through each heroku app and yield the local app name and the heroku app name. This will fork and create another
277
+ # child process for each heorku app.
278
+ def each_heroku_app
279
+ process_heroku_command do |local_names|
280
+ $stdout.sync = true # Sync up the bufferred output.
132
281
 
133
- # This fails on a newly created app
134
- system_with_echo("#{@config.cmd(app_env)} \"#{@config.rails_cli(:runner)} 'Rails.cache.clear'\" --app #{app_name}")
282
+ # Preload the colors before we parallelize any commands.
283
+ local_names.each do |local_name|
284
+ @assigned_colors[local_name] ||= Helper::COLORS[@assigned_colors.size % Helper::COLORS.size]
285
+ end
286
+
287
+ # Performs work in 4 processes, each process is tasked the same command but for a different
288
+ # heroku environment.
289
+ # We use processes to get around the GIL/GVL issues.
290
+ Parallel.each(local_names, :in_processes => 4) do |local_name|
291
+ remote_name = @config.heroku_app_name(local_name)
292
+ @displayer = Displayer.new(remote_name, @assigned_colors[local_name])
293
+ yield(local_name, remote_name)
135
294
  end
136
295
  end
137
296
  end
138
297
 
139
- # setup the addons for heroku
140
- def setup_addons
141
- authorize unless @heroku
142
- each_heroku_app do |heroku_env, app_name, repo|
143
- # get the addons that we are aiming towards
144
- addons = @config.addons(heroku_env)
145
-
146
- # get the addons that are already on the servers
147
- existing_addons = (@heroku.installed_addons(app_name) || []).map{|a| a["name"]}
148
-
149
- # all apps need the shared database
150
- addons << "shared-database:5mb" unless addons.any? {|x| x[/heroku-postgresql|shared-database|heroku-shared-postgresql|amazon_rds/]}
151
-
152
- # remove the addons that need to be removed
153
- existing_addons.each do |existing_addon|
154
- # check to see if we need to delete this addon
155
- unless addons.include?(existing_addon)
156
- # delete this addon if they arent on the approved list
157
- destroy_command "heroku addons:remove #{existing_addon} --app #{app_name} --confirm #{app_name}"
158
- end
298
+ # Internal methods
299
+ #---------------------------------------------------------------------------------------------------------------------
300
+ #
301
+ private
302
+ def _setup_app(local_name, remote_name)
303
+ if _setup_app?(remote_name)
304
+ params = {'name' => remote_name}
305
+ region = @config.region(local_name)
306
+
307
+ @displayer.labelize("Creating Heroku app: #{Helper.green(remote_name)}")
308
+
309
+ if region.present?
310
+ params.merge!('region' => region)
311
+ @displayer.labelize("\t Region: #{Helper.green(region)}")
159
312
  end
160
313
 
161
- # add the addons that dont exist already
162
- addons.each do |addon|
163
- # check to see if we need to add this addon
164
- unless existing_addons.include?(addon)
165
- # add this addon if they are not already added
166
- creation_command "heroku addons:add #{addon} --app #{app_name}"
314
+ heroku.post_app(params)
315
+ end
316
+ end
317
+
318
+ def _setup_app?(remote_name)
319
+ !heroku.get_apps.any? { |apps| apps["name"] == remote_name }
320
+ end
321
+
322
+ def _setup_collaborators(local_name, remote_name)
323
+ @displayer.labelize("Setting collaborators... ")
324
+
325
+ remote_collaborators = heroku.get_collaborators(remote_name).map { |collaborator| collaborator["email"] }
326
+ local_collaborators = @config.collaborators(local_name)
327
+
328
+ add_collaborators, delete_collaborators = self.class.deltas(local_collaborators, remote_collaborators)
329
+ apply(remote_name, add_collaborators, "post_collaborator", "Adding collaborator(s):")
330
+ apply(remote_name, delete_collaborators, "delete_collaborator", "Deleting collaborator(s):")
331
+ end
332
+
333
+ def _setup_addons(local_name, remote_name)
334
+ @displayer.labelize("Setting addons... ")
335
+
336
+ remote_addons = heroku.get_addons(remote_name).map { |addon| addon["name"] }
337
+ local_addons = @config.addons(local_name)
338
+
339
+ # Requires at the minimum a shared database.
340
+ local_addons << SHARED_DATABASE_ADDON unless local_addons.any? {|x| x[DATABASE_REGEX] }
341
+
342
+ add_addons, delete_addons = self.class.deltas(local_addons, remote_addons)
343
+ apply(remote_name, add_addons, "post_addon", "Adding addon(s):")
344
+ apply(remote_name, delete_addons, "delete_addon", "Deleting addon(s):")
345
+ end
346
+
347
+ def _setup_config(local_name, remote_name)
348
+ @displayer.labelize("Setting config... ")
349
+
350
+ remote_configs = heroku.get_config_vars(remote_name)
351
+ local_configs = @config.config(local_name)
352
+
353
+ delete_config_keys = []
354
+ add_configs = local_configs.delete_if do |key, value|
355
+ if value == CONFIG_DELETE_MARKER
356
+ delete_config_keys << key
357
+ elsif remote_configs.has_key?(key) && remote_configs[key] == value.to_s
358
+ true
359
+ end
360
+ end
361
+
362
+ perform_delete = delete_config_keys.present? &&
363
+ remote_configs.keys.any? { |key| delete_config_keys.include?(key) }
364
+
365
+ if add_configs.present?
366
+ @displayer.labelize("Adding config(s):")
367
+ add_configs.each do |key, value|
368
+ if value.is_a?(String) && value.include?("\n")
369
+ configs_values = value.split("\n")
370
+ @displayer.labelize("#{key.rjust(25)}: #{configs_values.shift}")
371
+ configs_values.each { |v| @displayer.labelize("#{''.rjust(25)} #{v}") }
372
+ else
373
+ @displayer.labelize("#{key.rjust(25)}: #{value}")
167
374
  end
168
375
  end
376
+ heroku.put_config_vars(remote_name, add_configs)
377
+ end
169
378
 
170
- # display the destructive commands
171
- output_destroy_commands(app_name)
379
+ if perform_delete
380
+ @displayer.labelize("Deleting config(s):")
381
+ delete_config_keys.each do |key|
382
+ @displayer.labelize("#{key.rjust(25)}: #{remote_configs[key]}")
383
+ heroku.delete_config_var(remote_name, key)
384
+ end
172
385
  end
173
386
  end
174
387
 
175
- # setup the domains for heroku
176
- def setup_domains
177
- authorize unless @heroku
178
- each_heroku_app do |heroku_env, app_name, repo|
179
- # get the domains that we are aiming towards
180
- domains = @config.domains(heroku_env)
181
-
182
- # get the domains that are already on the servers
183
- existing_domains = (@heroku.list_domains(app_name) || []).map{|a| a[:domain]}
184
-
185
- # remove the domains that need to be removed
186
- existing_domains.each do |existing_domain|
187
- # check to see if we need to delete this domain
188
- unless domains.include?(existing_domain)
189
- # delete this domain if they arent on the approved list
190
- destroy_command "heroku domains:remove #{existing_domain} --app #{app_name}"
388
+ def _setup_domains(local_name, remote_name)
389
+ @displayer.labelize("Setting domains... ")
390
+
391
+ remote_domains = heroku.get_domains(remote_name).map { |domain| domain["domain"] }
392
+ local_domains = @config.domains(local_name)
393
+ add_domains, delete_domains = self.class.deltas(local_domains, remote_domains)
394
+
395
+ delete_domains.delete_if { |domain| domain =~ DEFAULT_HEROKU_DOMAIN }
396
+
397
+ apply(remote_name, add_domains, "post_domain", "Adding domain(s):")
398
+ apply(remote_name, delete_domains, "delete_domain", "Deleting domain(s):")
399
+ end
400
+
401
+ def _maintenance(toggle, remote_name)
402
+ value = toggle ? '1' : 0
403
+ display = toggle ? Helper.green("ON") : Helper.red("OFF")
404
+ @displayer.labelize("Maintenance mode #{display}")
405
+ heroku.post_app_maintenance(remote_name, value)
406
+ end
407
+
408
+ def _restart(remote_name)
409
+ heroku.post_ps_restart(remote_name)
410
+ @displayer.labelize("Restarting... #{Helper.green('OK')}")
411
+ end
412
+
413
+ def _migrate(remote_name)
414
+ sync_exec(remote_name, "rake db:migrate")
415
+ end
416
+
417
+ def _scale(local_name, remote_name)
418
+ scaling = @config.scale(local_name)
419
+ types = scaling.keys
420
+
421
+ # Clock must be the last process to scale because it could require a worker dyno to be present
422
+ # since it can trigger a scheduling of a background job immediately after its state is up.
423
+ types << types.delete("clock")
424
+ types.each { |type| heroku.post_ps_scale(remote_name, type, scaling[type]) }
425
+ @displayer.labelize("Scaling ... #{Helper.green('OK')}")
426
+ end
427
+
428
+ def _logs(remote_name)
429
+ url = heroku.get_logs(remote_name, {:tail => 1})
430
+ uri = URI.parse(url)
431
+ http = Net::HTTP.new(uri.host, uri.port)
432
+
433
+ if uri.scheme == 'https'
434
+ http.use_ssl = true
435
+ if ENV["HEROKU_SSL_VERIFY"] == "disable"
436
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
437
+ else
438
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
439
+ http.ca_file = LOCAL_CA_FILE
440
+ http.verify_callback = lambda do |preverify_ok, ssl_context|
441
+ if (!preverify_ok) || ssl_context.error != 0
442
+ @displayer.labelize("WARNING: Unable to verify SSL certificate for #{host}\nTo disable SSL verification, run with HEROKU_SSL_VERIFY=disable")
443
+ end
444
+ true
191
445
  end
192
446
  end
447
+ end
193
448
 
194
- # add the domains that dont exist already
195
- domains.each do |domain|
196
- # check to see if we need to add this domain
197
- unless existing_domains.include?(domain)
198
- # add this domain if they are not already added
199
- creation_command "heroku domains:add #{domain} --app #{app_name}"
449
+ http.read_timeout = 60 * 60 * 24
450
+
451
+ begin
452
+ http.start do
453
+ http.request_get(uri.path + (uri.query ? "?" + uri.query : "")) do |request|
454
+ request.read_body do |chunk|
455
+ chunk.split("\n").each { |line| @displayer.labelize(line) }
456
+ end
200
457
  end
201
458
  end
202
-
203
- # display the destructive commands
204
- output_destroy_commands(app_name)
459
+ rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError
460
+ @displayer.labelize("Could not connect to logging service")
461
+ rescue Timeout::Error, EOFError
462
+ @displayer.labelize("\nRequest timed out")
205
463
  end
206
464
  end
207
465
 
208
- def scale
209
- authorize unless @heroku
210
- each_heroku_app do |heroku_env, app_name, repo|
211
- scaling = @config.scale(heroku_env)
212
- scaling.each do |process_name, instance_count|
213
- begin
214
- puts "Scaling app #{app_name} process #{process_name} to #{instance_count}"
215
- response = @heroku.ps_scale(app_name, {:type => process_name, :qty => instance_count })
216
- puts "Response: #{response}"
217
- rescue => e
218
- puts "Failed to scale #{app_name}. Error: #{e.inspect}"
466
+ def _console(remote_name)
467
+ data = heroku.post_ps(remote_name, 'console', {:attach => true})
468
+
469
+ Rendezvous.start(:url => data['rendezvous_url'])
470
+ end
471
+
472
+ # Prompt for a branch tag if any of the environments being deploy to is production grade.
473
+ # This serves as a sanity check against deploy directly to production environments.
474
+ def prompt_for_branch
475
+ deploy_branch = nil
476
+
477
+ if @local_names.any? {|local_name| local_name[regex_for(:production)] }
478
+ all_tags = `git tag`
479
+ target_tag = `git describe --tags --abbrev=0`.chomp # Set latest tag as default
480
+
481
+ begin
482
+ puts "\nGit tags:"
483
+ puts all_tags
484
+ print "\nPlease enter a tag to deploy (or hit Enter for \"#{target_tag}\"): "
485
+ input_tag = STDIN.gets.chomp
486
+ if input_tag.present?
487
+ if all_tags[/^#{input_tag}\n/].present?
488
+ target_tag = input_tag
489
+ invalid = false
490
+ else
491
+ puts "\n\nInvalid git tag!"
492
+ invalid = true
493
+ end
219
494
  end
495
+ end while invalid
496
+
497
+ if target_tag.empty?
498
+ puts "Unable to determine the tag to deploy."
499
+ exit(1)
500
+ end
501
+
502
+ deploy_branch = target_tag
503
+ else
504
+ deploy_branch = `git branch`.scan(/^\* (.*)\n/).flatten.first.to_s
505
+
506
+ if deploy_branch.empty?
507
+ puts "Unable to determine the current git branch, please checkout the branch you'd like to deploy."
508
+ exit(1)
220
509
  end
221
510
  end
511
+
512
+ deploy_branch
222
513
  end
223
514
 
224
- # cycles through each configured heroku app
225
- # yields the environment name, the app name, and the repo url
226
- def each_heroku_app
227
- if @config.apps.size == 0
228
- puts "\nNo heroku apps are configured. Run:
229
- rails generate heroku:config\n\n"
515
+ # Checks to see if there is at least one environment indicated to run commands against.
516
+ def process_heroku_command
517
+ if @config.apps.blank?
518
+ puts "\nNo heroku apps are configured. Run: rails generate heroku:config\n\n"
230
519
  puts "this will generate a default config/heroku.yml that you should edit"
231
520
  puts "and then try running this command again"
232
521
 
233
522
  exit(1)
234
523
  end
235
524
 
236
- if (@environments.nil? || @environments.empty?) && @config.apps.size == 1
237
- @environments = [all_environments(true).try(:first)].compact
238
- end
525
+ @local_names = [all_environments(true).try(:first)].compact if @local_names.empty?
239
526
 
240
- if @environments.present?
241
- @environments.each do |env|
242
- app_name = @config.app_name_on_heroku(env)
243
- yield(env, app_name, "git@heroku.com:#{app_name}.git")
244
- end
527
+ if @local_names.present?
528
+ yield(@local_names)
245
529
  else
246
530
  puts "\nYou must first specify at least one Heroku app:
247
531
  rake <app>:<environment> [<app>:<environment>] <command>
@@ -255,45 +539,28 @@ module HerokuRailsSaas
255
539
  end
256
540
  end
257
541
 
258
- def system_with_echo(*args)
259
- puts args.join(' ')
260
- command(*args)
261
- end
262
-
263
- def creation_command(*args)
264
- system_with_echo(*args)
265
- end
266
-
267
- def destroy_command(*args)
268
- # puts args.join(' ')
269
- @destroy_commands ||= []
270
- @destroy_commands << args.join(' ')
271
- end
272
-
273
- def output_destroy_commands(app)
274
- if @destroy_commands.try(:any?)
275
- puts "The #{app} had a few things removed from the heroku.yml."
276
- puts "If they are no longer neccessary, then run the following commands:\n\n"
277
- @destroy_commands.each do |destroy_command|
278
- puts destroy_command
542
+ # Apply heroku configurations changes.
543
+ # app - name of the remote heroku app.
544
+ # settings - list to apply method to.
545
+ # method - name of the api method to call.
546
+ # message - display of what actions are to be performed.
547
+ def apply(app, settings, method, message)
548
+ if settings.present?
549
+ @displayer.labelize(message)
550
+ settings.each do |setting|
551
+ @displayer.labelize("\t#{setting}")
552
+ heroku.__send__(method.to_sym, app, setting)
279
553
  end
280
- puts "\n\nthese commands may cause data loss so make sure you know that these are necessary"
281
554
  end
282
- # clear destroy commands
283
- @destroy_commands = []
284
555
  end
285
556
 
286
- def command(*args)
287
- raise "*** command \"#{args.join ' '}\" failed" unless system(*args)
288
- end
289
-
290
- def regex_for env
291
- match = case env
557
+ # Returns a regex to look for a specific type of an environment.
558
+ def regex_for(environment)
559
+ match = case environment
292
560
  when :production then "production|prod|live"
293
561
  when :staging then "staging|stage"
294
562
  end
295
563
  Regexp.new("#{@config.class::SEPERATOR}(#{match})")
296
564
  end
297
-
298
565
  end
299
566
  end