heroku_hatchet 6.0.0 → 7.0.0

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.
@@ -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"