heroku_hatchet 6.0.0 → 7.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -46,7 +46,7 @@ class HatchetCLI < Thor
46
46
  Threaded.later do
47
47
  commit = lock_hash[directory]
48
48
  directory = File.expand_path(directory)
49
- if Dir[directory].present?
49
+ if !Dir[directory]&.empty?
50
50
  puts "== pulling '#{git_repo}' into '#{directory}'\n"
51
51
  pull(directory, git_repo)
52
52
  else
@@ -78,6 +78,8 @@ class HatchetCLI < Thor
78
78
 
79
79
  if lockfile_hash[directory] == "master"
80
80
  lock_hash[directory] = "master"
81
+ elsif lockfile_hash[directory] == "main"
82
+ lock_hash[directory] = "main"
81
83
  else
82
84
  commit = commit_at_directory(directory)
83
85
  lock_hash[directory] = commit
@@ -157,7 +159,7 @@ class HatchetCLI < Thor
157
159
  end
158
160
 
159
161
  def pull(path, git_repo, commit: false)
160
- cmd("cd #{path} && git pull --rebase #{git_repo} master --quiet")
162
+ cmd("cd #{path} && git pull --rebase #{git_repo} --quiet")
161
163
  end
162
164
 
163
165
  def clone(path, git_repo, quiet: true)
@@ -18,11 +18,10 @@ Gem::Specification.new do |gem|
18
18
  gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
19
19
  gem.require_paths = ["lib"]
20
20
 
21
- gem.add_dependency "platform-api", "3.0.0.pre.1 "
21
+ gem.add_dependency "platform-api", "~> 3"
22
22
  gem.add_dependency "rrrretry", "~> 1"
23
23
  gem.add_dependency "excon", "~> 0"
24
24
  gem.add_dependency "thor", "~> 0"
25
- gem.add_dependency "repl_runner", "~> 0.0.3"
26
25
  gem.add_dependency "threaded", "~> 0"
27
26
 
28
27
  gem.add_development_dependency "rspec"
@@ -12,6 +12,7 @@
12
12
  ],
13
13
  "lock": [
14
14
  "sharpstone/lock_fail",
15
- "sharpstone/lock_fail_master"
15
+ "sharpstone/lock_fail_master",
16
+ "sharpstone/lock_fail_main"
16
17
  ]
17
18
  }
@@ -9,6 +9,8 @@
9
9
  - 6e642963acec0ff64af51bd6fba8db3c4176ed6e
10
10
  - - repo_fixtures/repos/lock/lock_fail
11
11
  - da748a59340be8b950e7bbbfb32077eb67d70c3c
12
+ - - repo_fixtures/repos/lock/lock_fail_main
13
+ - main
12
14
  - - repo_fixtures/repos/lock/lock_fail_master
13
15
  - master
14
16
  - - repo_fixtures/repos/rails2/rails2blog
@@ -1,4 +1,3 @@
1
- require 'active_support/core_ext/object/blank'
2
1
  require 'rrrretry'
3
2
 
4
3
  require 'json'
@@ -8,7 +7,7 @@ require 'stringio'
8
7
  require 'date'
9
8
 
10
9
  module Hatchet
11
- APP_PREFIX = (ENV['HATCHET_APP_PREFIX'] || "hatchet-t-")
10
+ APP_PREFIX = (ENV['HATCHET_APP_PREFIX'] || "hatchet-t-")
12
11
  end
13
12
 
14
13
  require 'hatchet/version'
@@ -1,14 +1,11 @@
1
- # Wraps platform-api and adds API rate limits
1
+ # Legacy class
2
2
  #
3
- # Instead of:
4
- #
5
- # platform_api.pipeline.create(name: @name)
6
- #
7
- # Use:
8
- #
9
- # api_rate_limit = ApiRateLimit.new(platform_api)
10
- # api_rate_limit.call.pipeline.create(name: @name)
3
+ # Not needed since rate throttling went directly into the platform-api gem.
4
+ # This class is effectively now a no-op
11
5
  #
6
+ # It's being left in as it's interface was public and it's hard-ish to
7
+ # deprecate/remove. Since it's so small there's not much value in removal
8
+ # so it's probably fine to keep around for quite some time.
12
9
  class ApiRateLimit
13
10
  def initialize(platform_api)
14
11
  @platform_api = platform_api
@@ -16,14 +13,6 @@ class ApiRateLimit
16
13
  @called = 0
17
14
  end
18
15
 
19
-
20
- # Sleeps for progressively longer when api rate limit capacity
21
- # is lower.
22
- #
23
- # Unfortunatley `@platform_api.rate_limit` is an extra API
24
- # call, so by checking our limit, we also are using our limit 😬
25
- # to partially mitigate this, only check capacity every 5
26
- # api calls, or if the current capacity is under 1000
27
16
  def call
28
17
  # @called += 1
29
18
 
@@ -9,14 +9,34 @@ module Hatchet
9
9
  HATCHET_BUILDPACK_BRANCH = -> { ENV['HATCHET_BUILDPACK_BRANCH'] || ENV['HEROKU_TEST_RUN_BRANCH'] || Hatchet.git_branch }
10
10
  BUILDPACK_URL = "https://github.com/heroku/heroku-buildpack-ruby.git"
11
11
 
12
- attr_reader :name, :stack, :directory, :repo_name, :app_config, :buildpacks
13
-
14
- class FailedDeploy < StandardError
15
- def initialize(app, output)
16
- msg = "Could not deploy '#{app.name}' (#{app.repo_name}) using '#{app.class}' at path: '#{app.directory}'\n" <<
17
- " if this was expected add `allow_failure: true` to your deploy hash.\n" <<
18
- "output:\n" <<
19
- "#{output}"
12
+ attr_reader :name, :stack, :directory, :repo_name, :app_config, :buildpacks, :reaper
13
+
14
+ class FailedDeploy < StandardError; end
15
+
16
+ class FailedDeployError < FailedDeploy
17
+ attr_reader :output
18
+
19
+ def initialize(app, message, output: )
20
+ @output = output
21
+ msg = "Could not deploy '#{app.name}' (#{app.repo_name}) using '#{app.class}' at path: '#{app.directory}'\n"
22
+ msg << "if this was expected add `allow_failure: true` to your deploy hash.\n"
23
+ msg << "#{message}\n"
24
+ msg << "output:\n"
25
+ msg << "#{output}"
26
+ super(msg)
27
+ end
28
+ end
29
+
30
+ class FailedReleaseError < FailedDeploy
31
+ attr_reader :output
32
+
33
+ def initialize(app, message, output: )
34
+ @output = output
35
+ msg = "Could not release '#{app.name}' (#{app.repo_name}) using '#{app.class}' at path: '#{app.directory}'\n"
36
+ msg << "if this was expected add `allow_failure: true` to your deploy hash.\n"
37
+ msg << "#{message}\n"
38
+ msg << "output:\n"
39
+ msg << "#{output}"
20
40
  super(msg)
21
41
  end
22
42
  end
@@ -34,6 +54,7 @@ module Hatchet
34
54
  buildpacks: nil,
35
55
  buildpack_url: nil,
36
56
  before_deploy: nil,
57
+ run_multi: ENV["HATCHET_RUN_MULTI"],
37
58
  config: {}
38
59
  )
39
60
  @repo_name = repo_name
@@ -46,6 +67,12 @@ module Hatchet
46
67
  @buildpacks = buildpack || buildpacks || buildpack_url || self.class.default_buildpack
47
68
  @buildpacks = Array(@buildpacks)
48
69
  @buildpacks.map! {|b| b == :default ? self.class.default_buildpack : b}
70
+ @run_multi = run_multi
71
+
72
+ if run_multi && !ENV["HATCHET_EXPENSIVE_MODE"]
73
+ raise "You're attempting to enable `run_multi: true` mode, but have not enabled `HATCHET_EXPENSIVE_MODE=1` env var to verify you understand the risks"
74
+ end
75
+ @run_multi_array = []
49
76
  @already_in_dir = nil
50
77
  @app_is_setup = nil
51
78
 
@@ -127,6 +154,22 @@ module Hatchet
127
154
  else
128
155
  command = command.to_s
129
156
  end
157
+
158
+ heroku_command = build_heroku_command(command, options)
159
+
160
+ allow_run_multi! if @run_multi
161
+
162
+ output = ""
163
+
164
+ ShellThrottle.new(platform_api: @platform_api).call do |throttle|
165
+ output = `#{heroku_command}`
166
+ throw(:throttle) if output.match?(/reached the API rate limit/)
167
+ end
168
+
169
+ return output
170
+ end
171
+
172
+ private def build_heroku_command(command, options = {})
130
173
  command = command.shellescape unless options.delete(:raw)
131
174
 
132
175
  default_options = { "app" => name, "exit-code" => nil }
@@ -136,16 +179,77 @@ module Hatchet
136
179
  arg << "=#{v.to_s.shellescape}" unless v.nil? # nil means we include the option without an argument
137
180
  arg
138
181
  end.join(" ")
139
- heroku_command = "heroku run #{heroku_options} -- #{command}"
140
182
 
141
- if block_given?
142
- STDERR.puts "Using App#run with a block is deprecated, support for ReplRunner is being removed.\n#{caller}"
143
- # When we deprecated this we can get rid of the "cmd_type" from the method signature
144
- require 'repl_runner'
145
- ReplRunner.new(cmd_type, heroku_command, options).run(&block)
146
- else
147
- `#{heroku_command}`
183
+ "heroku run #{heroku_options} -- #{command}"
184
+ end
185
+
186
+ private def allow_run_multi!
187
+ raise "Must explicitly enable the `run_multi: true` option. This requires scaling up a dyno and is not free, it may incur charges on your account" unless @run_multi
188
+
189
+ @run_multi_is_setup ||= platform_api.formation.update(name, "web", {"size" => "Standard-1X"})
190
+ end
191
+
192
+
193
+ # Allows multiple commands to be run concurrently in the background.
194
+ #
195
+ # WARNING! Using the feature requres that the underlying app is not on the "free" Heroku
196
+ # tier. This requires scaling up the dyno which is not free. If an app is
197
+ # scaled up and left in that state it can incur large costs.
198
+ #
199
+ # Enabling this feature should be done with extreme caution.
200
+ #
201
+ # Example:
202
+ #
203
+ # Hatchet::Runner.new("default_ruby", run_multi: true)
204
+ # app.run_multi("ls") { |out| expect(out).to include("Gemfile") }
205
+ # app.run_multi("ruby -v") { |out| expect(out).to include("ruby") }
206
+ # end
207
+ #
208
+ # This example will run `heroku run ls` as well as `ruby -v` at the same time in the background.
209
+ # The return result will be yielded to the block after they finish running.
210
+ #
211
+ # Order of execution is not guaranteed.
212
+ #
213
+ # If you need to assert a command was successful, you can yield a second status object like this:
214
+ #
215
+ # Hatchet::Runner.new("default_ruby", run_multi: true)
216
+ # app.run_multi("ls") do |out, status|
217
+ # expect(status.success?).to be_truthy
218
+ # expect(out).to include("Gemfile")
219
+ # end
220
+ # app.run_multi("ruby -v") do |out, status|
221
+ # expect(status.success?).to be_truthy
222
+ # expect(out).to include("ruby")
223
+ # end
224
+ # end
225
+ def run_multi(command, options = {}, &block)
226
+ raise "Block required" if block.nil?
227
+ allow_run_multi!
228
+
229
+ run_thread = Thread.new do
230
+ heroku_command = build_heroku_command(command, options)
231
+
232
+ out = nil
233
+ status = nil
234
+ ShellThrottle.new(platform_api: @platform_api).call do |throttle|
235
+ out = `#{heroku_command}`
236
+ throw(:throttle) if output.match?(/reached the API rate limit/)
237
+ status = $?
238
+ end
239
+
240
+ yield out, status
241
+
242
+ # if block.arity == 1
243
+ # block.call(out)
244
+ # else
245
+ # block.call(out, status)
246
+ # end
148
247
  end
248
+ run_thread.abort_on_exception = true
249
+
250
+ @run_multi_array << run_thread
251
+
252
+ true
149
253
  end
150
254
 
151
255
  # set debug: true when creating app if you don't want it to be
@@ -162,24 +266,26 @@ module Hatchet
162
266
  alias :no_debug? :not_debugging?
163
267
 
164
268
  def deployed?
165
- # !heroku.get_ps(name).body.detect {|ps| ps["process"].include?("web") }.nil?
166
269
  api_rate_limit.call.formation.list(name).detect {|ps| ps["type"] == "web"}
167
270
  end
168
271
 
169
272
  def create_app
170
273
  3.times.retry do
171
274
  begin
172
- # heroku.post_app({ name: name, stack: stack }.delete_if {|k,v| v.nil? })
173
275
  hash = { name: name, stack: stack }
174
276
  hash.delete_if { |k,v| v.nil? }
175
- api_rate_limit.call.app.create(hash)
277
+ heroku_api_create_app(hash)
176
278
  rescue => e
177
- @reaper.cycle
279
+ @reaper.cycle(app_exception_message: e.message)
178
280
  raise e
179
281
  end
180
282
  end
181
283
  end
182
284
 
285
+ private def heroku_api_create_app(hash)
286
+ api_rate_limit.call.app.create(hash)
287
+ end
288
+
183
289
  def update_stack(stack_name)
184
290
  @stack = stack_name
185
291
  api_rate_limit.call.app.update(name, build_stack: @stack)
@@ -219,11 +325,15 @@ module Hatchet
219
325
 
220
326
  def teardown!
221
327
  return false unless @app_is_setup
222
- if debugging?
223
- puts "Debugging App:#{name}"
224
- return false
328
+
329
+ if @run_multi_is_setup
330
+ @run_multi_array.map(&:join)
331
+ platform_api.formation.update(name, "web", {"size" => "free"})
225
332
  end
226
- @reaper.cycle
333
+
334
+ ensure
335
+ @app_update_info = platform_api.app.update(name, { maintenance: true }) if @app_is_setup
336
+ @reaper.cycle if @app_is_setup
227
337
  end
228
338
 
229
339
  def in_directory(directory = self.directory)
@@ -265,10 +375,6 @@ module Hatchet
265
375
  end
266
376
  end
267
377
 
268
- # creates a new app on heroku, "pushes" via anvil or git
269
- # then yields to self so you can call self.run or
270
- # self.deployed?
271
- # Allow deploy failures on CI server by setting ENV['HATCHET_RETRIES']
272
378
  def deploy(&block)
273
379
  in_directory do
274
380
  self.setup!
@@ -276,7 +382,7 @@ module Hatchet
276
382
  block.call(self, api_rate_limit.call, output) if block_given?
277
383
  end
278
384
  ensure
279
- self.teardown!
385
+ self.teardown! if block_given?
280
386
  end
281
387
 
282
388
  def push
@@ -346,6 +452,7 @@ module Hatchet
346
452
  test_run.wait!(&block)
347
453
  end
348
454
  ensure
455
+ teardown! if block_given?
349
456
  delete_pipeline(@pipeline_id) if @pipeline_id
350
457
  @pipeline_id = nil
351
458
  end
@@ -381,7 +488,6 @@ module Hatchet
381
488
  end
382
489
 
383
490
  def platform_api
384
- puts "Deprecated: use `api_rate_limit.call` instead of platform_api"
385
491
  api_rate_limit
386
492
  return @platform_api
387
493
  end
@@ -433,3 +539,4 @@ module Hatchet
433
539
  end
434
540
  end
435
541
 
542
+ require_relative 'shell_throttle.rb'
@@ -46,7 +46,7 @@ module Hatchet
46
46
  def path_for_name(name)
47
47
  possible_paths = [repos[name.to_s], "repos/#{name}", name].compact
48
48
  path = possible_paths.detect do |path|
49
- Dir[path].present?
49
+ !Dir[path]&.empty?
50
50
  end
51
51
  raise BadRepoName.new(name, possible_paths) if path.nil? || path.empty?
52
52
  path
@@ -5,11 +5,37 @@ module Hatchet
5
5
  "https://git.heroku.com/#{name}.git"
6
6
  end
7
7
 
8
+
8
9
  def push_without_retry!
10
+ output = ""
11
+
12
+ ShellThrottle.new(platform_api: @platform_api).call do
13
+ output = git_push_heroku_yall
14
+ rescue FailedDeploy => e
15
+ if e.output.match?(/reached the API rate limit/)
16
+ throw(:throttle)
17
+ elsif @allow_failure
18
+ output = e.output
19
+ else
20
+ raise e
21
+ end
22
+ end
23
+
24
+ return output
25
+ end
26
+
27
+ private def git_push_heroku_yall
9
28
  output = `git push #{git_repo} master 2>&1`
29
+
10
30
  if !$?.success?
11
- raise FailedDeploy.new(self, "Buildpack: #{@buildpack.inspect}\nRepo: #{git_repo}\n#{output}") unless @allow_failure
31
+ raise FailedDeployError.new(self, "Buildpack: #{@buildpack.inspect}\nRepo: #{git_repo}", output: output)
12
32
  end
33
+
34
+ releases = platform_api.release.list(name)
35
+ if releases.last["status"] == "failed"
36
+ raise FailedReleaseError.new(self, "Buildpack: #{@buildpack.inspect}\nRepo: #{git_repo}", output: output)
37
+ end
38
+
13
39
  return output
14
40
  end
15
41
  end
@@ -1,88 +1,191 @@
1
1
  require 'tmpdir'
2
2
 
3
3
  module Hatchet
4
- # Hatchet apps are useful after the tests run for debugging purposes
5
- # the reaper is designed to allow the most recent apps to stay alive
6
- # while keeping the total number of apps under the global Heroku limit.
7
- # Any time you're worried about hitting the limit call @reaper.cycle
4
+ # This class lazilly deletes hatchet apps
8
5
  #
6
+ # When the reaper is called, it will check if the system has too many apps (Bassed off of HATCHET_APP_LIMIT), if so it will attempt
7
+ # to delete an app to free up capacity. The goal of lazilly deleting apps is to temporarilly keep
8
+ # apps around for debugging if they fail.
9
+ #
10
+ # When App#teardown! is called on an app it is marked as being in a "finished" state by turning
11
+ # on maintenance mode. The reaper will delete these in order (oldest first).
12
+ #
13
+ # If no apps are marked as being "finished" then the reaper will check to see if the oldest app
14
+ # has been alive for a long enough period for it's tests to finish (configured by HATCHET_ALIVE_TTL_MINUTES env var).
15
+ # If the "unfinished" app has been alive that long it will be deleted. If not, the system will sleep for a period of time
16
+ # in an attempt to allow other apps to move to be "finished".
17
+ #
18
+ # This class only limits and the number of "hatchet" apps on the system. Prevously there was a maximum of 100 apps on a
19
+ # Heroku account. Now a user can belong to multiple orgs and the total number of apps they have access to is no longer
20
+ # fixed at 100. Instead of hard coding a maximum limit, this failure mode is handled by forcing deletion of
21
+ # an app when app creation fails. In the future we may find a better way of detecting this failure mode
22
+ #
23
+ # Notes:
24
+ #
25
+ # - The class uses a file mutex so that multiple processes on the same machine do not attempt to run the
26
+ # reaper at the same time.
27
+ # - AlreadyDeletedError will be raised if an app has already been deleted (possibly by another test run on
28
+ # another machine). When this happens, the system will automatically attempt to reap another app.
9
29
  class Reaper
10
- HEROKU_APP_LIMIT = Integer(ENV["HEROKU_APP_LIMIT"] || 100) # the number of apps heroku allows you to keep
11
- HATCHET_APP_LIMT = Integer(ENV["HATCHET_APP_LIMIT"] || 20) # the number of apps hatchet keeps around
30
+ class AlreadyDeletedError < StandardError; end
31
+
32
+ HATCHET_APP_LIMIT = Integer(ENV["HATCHET_APP_LIMIT"] || 20) # the number of apps hatchet keeps around
12
33
  DEFAULT_REGEX = /^#{Regexp.escape(Hatchet::APP_PREFIX)}[a-f0-9]+/
13
- attr_accessor :apps
14
34
 
15
- def initialize(api_rate_limit: , regex: DEFAULT_REGEX)
35
+ attr_accessor :io, :hatchet_app_limit
36
+
37
+ def initialize(api_rate_limit: , regex: DEFAULT_REGEX, io: STDOUT, hatchet_app_limit: HATCHET_APP_LIMIT, initial_sleep: 10)
16
38
  @api_rate_limit = api_rate_limit
17
- @regex = regex
39
+ @regex = regex
40
+ @io = io
41
+ @finished_hatchet_apps = []
42
+ @unfinished_hatchet_apps = []
43
+ @app_count = 0
44
+ @hatchet_app_limit = hatchet_app_limit
45
+ @reaper_throttle = ReaperThrottle.new(initial_sleep: initial_sleep)
46
+ end
47
+
48
+ def cycle(app_exception_message: false)
49
+ # Protect against parallel deletion of the same app on the same system
50
+ mutex_file = File.open("#{Dir.tmpdir()}/hatchet_reaper_mutex", File::CREAT)
51
+ mutex_file.flock(File::LOCK_EX)
52
+
53
+ refresh_app_list if @finished_hatchet_apps.empty?
54
+
55
+ # To be safe try to delete an app even if we're not over the limit
56
+ # since the exception may have been caused by going over the maximum account limit
57
+ if app_exception_message
58
+ io.puts <<~EOM
59
+ WARNING: Running reaper due to exception on app
60
+ #{stats_string}
61
+ Exception: #{app_exception_message}
62
+ EOM
63
+ reap_once
64
+ end
65
+
66
+ while over_limit?
67
+ reap_once
68
+ end
69
+ ensure
70
+ mutex_file.close
18
71
  end
19
72
 
20
- # Ascending order, oldest is last
21
- def get_apps
22
- apps = @api_rate_limit.call.app.list.sort_by { |app| DateTime.parse(app["created_at"]) }.reverse
23
- @app_count = apps.count
24
- @hatchet_apps = apps.select {|app| app["name"].match(@regex) }
73
+ def stats_string
74
+ "total_app_count: #{@app_count}, hatchet_app_count: #{hatchet_app_count}/#{HATCHET_APP_LIMIT}, finished: #{@finished_hatchet_apps.length}, unfinished: #{@unfinished_hatchet_apps.length}"
25
75
  end
26
76
 
27
- def cycle
28
- # we don't want multiple Hatchet processes (e.g. when using rspec-parallel) to delete apps at the same time
29
- # this could otherwise result in race conditions in API causing errors other than 404s, making tests fail
30
- mutex = File.open("#{Dir.tmpdir()}/hatchet_reaper_mutex", File::CREAT)
31
- mutex.flock(File::LOCK_EX)
77
+ def over_limit?
78
+ hatchet_app_count > hatchet_app_limit
79
+ end
32
80
 
33
- # update list of apps once
81
+ # No guardrails, will delete all apps that match the hatchet namespace
82
+ def destroy_all
34
83
  get_apps
35
84
 
36
- return unless over_limit?
37
-
38
- while over_limit?
39
- if @hatchet_apps.count > 1
40
- # remove our own apps until we are below limit
41
- destroy_oldest
42
- else
43
- puts "Warning: Reached Heroku app limit of #{HEROKU_APP_LIMIT}."
44
- break
85
+ (@finished_hatchet_apps + @unfinished_hatchet_apps).each do |app|
86
+ begin
87
+ destroy_with_log(name: app["name"], id: app["id"])
88
+ rescue AlreadyDeletedError
89
+ # Ignore, keep going
45
90
  end
46
91
  end
92
+ end
47
93
 
48
- # If the app is already deleted an exception
49
- # will be raised, if the app cannot be found
50
- # assume it is already deleted and try again
51
- rescue Excon::Error::NotFound => e
52
- body = e.response.body
53
- if body =~ /Couldn\'t find that app./
54
- puts "#{@message}, but looks like it was already deleted"
55
- mutex.close # ensure only gets called on block exit and not on `retry`
56
- retry
94
+ private def reap_once
95
+ refresh_app_list if @finished_hatchet_apps.empty?
96
+
97
+ if (app = @finished_hatchet_apps.pop)
98
+ destroy_with_log(name: app["name"], id: app["id"])
99
+ elsif (app = @unfinished_hatchet_apps.pop)
100
+ destroy_if_old_enough(app)
57
101
  end
58
- raise e
59
- ensure
60
- # don't forget to close the mutex; this also releases our lock
61
- mutex.close
102
+ rescue AlreadyDeletedError
103
+ retry
62
104
  end
63
105
 
64
- def destroy_oldest
65
- oldest = @hatchet_apps.pop
66
- destroy_by_id(name: oldest["name"], id: oldest["id"], details: "Hatchet app limit: #{HATCHET_APP_LIMT}")
106
+ # Checks to see if the given app is older than the HATCHET_ALIVE_TTL_MINUTES
107
+ # if so, then the app is deleted, otherwise the reaper sleeps for a period of time after which
108
+ # It can try again to delete another app. The hope is that some apps will be marked as finished
109
+ # in that time
110
+ private def destroy_if_old_enough(app)
111
+ age = AppAge.new(
112
+ created_at: app["created_at"],
113
+ ttl_minutes: ENV.fetch("HATCHET_ALIVE_TTL_MINUTES", "7").to_i
114
+ )
115
+ if age.can_delete?
116
+ io.puts "WARNING: Destroying an app without maintenance mode on, app: #{app["name"]}, app_age: #{age.in_minutes} minutes"
117
+
118
+ destroy_with_log(name: app["name"], id: app["id"])
119
+ else
120
+ # We're not going to delete it yet, so put it back
121
+ @unfinished_hatchet_apps << app
122
+
123
+ # Sleep, try again later
124
+ @reaper_throttle.call(max_sleep: age.sleep_for_ttl) do |sleep_for|
125
+ io.puts <<~EOM
126
+ WARNING: Attempting to destroy an app without maintenance mode on, but it is not old enough. app: #{app["name"]}, app_age: #{age.in_minutes} minutes
127
+ This can happen if App#teardown! is not called on an application, which will leave it in an 'unfinished' state
128
+ This can also happen if you're trying to run more tests concurrently than your currently set value for HATCHET_APP_COUNT
129
+ Sleeping: #{sleep_for} seconds before trying to find another app to reap"
130
+ #{stats_string}, HATCHET_ALIVE_TTL_MINUTES=#{age.ttl_minutes}
131
+ EOM
132
+
133
+ sleep(sleep_for)
134
+ end
135
+ end
67
136
  end
68
137
 
69
- def destroy_all
70
- get_apps
71
- @hatchet_apps.each do |app|
72
- destroy_by_id(name: app["name"], id: app["id"])
138
+ private def get_heroku_apps
139
+ @api_rate_limit.call.app.list
140
+ end
141
+
142
+ private def refresh_app_list
143
+ apps = get_heroku_apps.
144
+ map {|app| app["created_at"] = DateTime.parse(app["created_at"].to_s); app }.
145
+ sort_by { |app| app["created_at"] }.
146
+ reverse # Ascending order, oldest is last
147
+
148
+ @app_count = apps.length
149
+
150
+ @finished_hatchet_apps.clear
151
+ @unfinished_hatchet_apps.clear
152
+ apps.each do |app|
153
+ next unless app["name"].match(@regex)
154
+
155
+ if app["maintenance"]
156
+ @finished_hatchet_apps << app
157
+ else
158
+ @unfinished_hatchet_apps << app
159
+ end
73
160
  end
74
161
  end
75
162
 
76
- def destroy_by_id(name:, id:, details: "")
77
- @message = "Destroying #{name.inspect}: #{id}. #{details}"
78
- puts @message
163
+ private def destroy_with_log(name:, id:)
164
+ message = "Destroying #{name.inspect}: #{id}, #{stats_string}"
165
+
79
166
  @api_rate_limit.call.app.delete(id)
80
- end
81
167
 
82
- private
168
+ io.puts message
169
+ rescue Excon::Error::NotFound => e
170
+ body = e.response.body
171
+ request_id = e.response.headers["Request-Id"]
172
+ if body =~ /Couldn\'t find that app./
173
+ io.puts "Duplicate destoy attempted #{name.inspect}: #{id}, status: 404, request_id: #{request_id}"
174
+ raise AlreadyDeletedError.new
175
+ else
176
+ raise e
177
+ end
178
+ rescue Excon::Error::Forbidden => e
179
+ request_id = e.response.headers["Request-Id"]
180
+ io.puts "Duplicate destoy attempted #{name.inspect}: #{id}, status: 403, request_id: #{request_id}"
181
+ raise AlreadyDeletedError.new
182
+ end
83
183
 
84
- def over_limit?
85
- @app_count > HEROKU_APP_LIMIT || @hatchet_apps.count > HATCHET_APP_LIMT
184
+ private def hatchet_app_count
185
+ @finished_hatchet_apps.length + @unfinished_hatchet_apps.length
86
186
  end
87
187
  end
88
188
  end
189
+
190
+ require_relative "reaper/app_age"
191
+ require_relative "reaper/reaper_throttle"