heroku-rails-saas 0.1.7 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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