heroku_hatchet 5.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +69 -0
  3. data/.gitignore +2 -0
  4. data/CHANGELOG.md +32 -1
  5. data/Gemfile +0 -1
  6. data/README.md +772 -205
  7. data/bin/hatchet +11 -4
  8. data/etc/ci_setup.rb +21 -15
  9. data/etc/setup_heroku.sh +0 -2
  10. data/hatchet.gemspec +4 -5
  11. data/hatchet.json +6 -2
  12. data/hatchet.lock +12 -8
  13. data/lib/hatchet.rb +1 -2
  14. data/lib/hatchet/api_rate_limit.rb +13 -24
  15. data/lib/hatchet/app.rb +159 -53
  16. data/lib/hatchet/config.rb +1 -1
  17. data/lib/hatchet/git_app.rb +27 -1
  18. data/lib/hatchet/reaper.rb +159 -56
  19. data/lib/hatchet/reaper/app_age.rb +49 -0
  20. data/lib/hatchet/reaper/reaper_throttle.rb +55 -0
  21. data/lib/hatchet/shell_throttle.rb +71 -0
  22. data/lib/hatchet/test_run.rb +16 -9
  23. data/lib/hatchet/version.rb +1 -1
  24. data/{test → repo_fixtures}/different-folder-for-checked-in-repos/default_ruby/Gemfile +0 -0
  25. data/spec/hatchet/allow_failure_git_spec.rb +40 -0
  26. data/spec/hatchet/app_spec.rb +226 -0
  27. data/spec/hatchet/ci_spec.rb +67 -0
  28. data/spec/hatchet/config_spec.rb +34 -0
  29. data/spec/hatchet/edit_repo_spec.rb +17 -0
  30. data/spec/hatchet/git_spec.rb +9 -0
  31. data/spec/hatchet/heroku_api_spec.rb +30 -0
  32. data/spec/hatchet/local_repo_spec.rb +26 -0
  33. data/spec/hatchet/lock_spec.rb +30 -0
  34. data/spec/spec_helper.rb +25 -0
  35. data/spec/unit/reaper_spec.rb +153 -0
  36. data/spec/unit/shell_throttle.rb +28 -0
  37. metadata +57 -86
  38. data/.travis.yml +0 -16
  39. data/test/fixtures/buildpacks/null-buildpack/bin/compile +0 -4
  40. data/test/fixtures/buildpacks/null-buildpack/bin/detect +0 -5
  41. data/test/fixtures/buildpacks/null-buildpack/bin/release +0 -3
  42. data/test/fixtures/buildpacks/null-buildpack/hatchet.json +0 -4
  43. data/test/fixtures/buildpacks/null-buildpack/readme.md +0 -41
  44. data/test/hatchet/allow_failure_git_test.rb +0 -16
  45. data/test/hatchet/app_test.rb +0 -96
  46. data/test/hatchet/ci_four_test.rb +0 -19
  47. data/test/hatchet/ci_test.rb +0 -11
  48. data/test/hatchet/ci_three_test.rb +0 -9
  49. data/test/hatchet/ci_too_test.rb +0 -19
  50. data/test/hatchet/config_test.rb +0 -51
  51. data/test/hatchet/edit_repo_test.rb +0 -20
  52. data/test/hatchet/git_test.rb +0 -16
  53. data/test/hatchet/heroku_api_test.rb +0 -30
  54. data/test/hatchet/labs_test.rb +0 -20
  55. data/test/hatchet/local_repo_test.rb +0 -26
  56. data/test/hatchet/lock_test.rb +0 -9
  57. data/test/hatchet/multi_cmd_runner_test.rb +0 -30
  58. data/test/test_helper.rb +0 -28
@@ -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"
@@ -0,0 +1,49 @@
1
+ module Hatchet
2
+ class Reaper
3
+ # Class for figuring out how old a given time is relative to another time
4
+ #
5
+ # Expects inputs as a DateTime instance
6
+ #
7
+ # Example:
8
+ #
9
+ # time_now = DateTime.parse("2020-07-28T14:40:00Z")
10
+ # age = AppAge.new(created_at: DateTIme.parse("2020-07-28T14:40:00Z"), time_now: time_now, ttl_minutes: 1)
11
+ # age.in_minutes => 0.0
12
+ # age.too_young_to_die? # => true
13
+ # age.can_delete? # => false
14
+ # age.sleep_for_ttl #=> 60
15
+ class AppAge
16
+ SECONDS_IN_A_DAY = 24 * 60 * 60
17
+
18
+ attr_reader :ttl_minutes
19
+
20
+ def initialize(created_at:, ttl_minutes:, time_now: DateTime.now.new_offset(0))
21
+ @seconds_ago = date_time_diff_in_seconds(time_now, created_at)
22
+ @ttl_minutes = ttl_minutes
23
+ @ttl_seconds = ttl_minutes * 60
24
+ end
25
+
26
+ def date_time_diff_in_seconds(now, whence)
27
+ (now - whence) * SECONDS_IN_A_DAY
28
+ end
29
+
30
+ def too_young_to_die?
31
+ !can_delete?
32
+ end
33
+
34
+ def can_delete?
35
+ @seconds_ago > @ttl_seconds
36
+ end
37
+
38
+ def sleep_for_ttl
39
+ return 0 if can_delete?
40
+
41
+ @ttl_seconds - @seconds_ago
42
+ end
43
+
44
+ def in_minutes
45
+ (@seconds_ago / 60.0).round(2)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,55 @@
1
+ module Hatchet
2
+ class Reaper
3
+ # This class retains and increments a sleep value between executions
4
+ #
5
+ # Every time we pause, we increase the duration of the pause 2x. If we
6
+ # do not sleep for long enough then we will burn API requests that we don't need to make.
7
+ #
8
+ # To help prevent sleeping for too long, the reaper will sleep for a maximum amount of time
9
+ # equal to the age_sleep_for_ttl. If that happens, it's likely a fairly large value and the
10
+ # internal incremental value can be reset
11
+ #
12
+ # Example:
13
+ #
14
+ # reaper_throttle = ReaperThrottle.new(initial_sleep: 2)
15
+ # reaper_throttle.call(max_sleep: 5) do |sleep_for|
16
+ # puts sleep_for # => 2
17
+ # end
18
+ # reaper_throttle.call(max_sleep: 5) do |sleep_for|
19
+ # puts sleep_for # => 4
20
+ # end
21
+ # reaper_throttle.call(max_sleep: 5) do |sleep_for|
22
+ # puts sleep_for # => 5
23
+ # end
24
+ #
25
+ # # The throttle is now reset since it hit the max_sleep value
26
+ #
27
+ # reaper_throttle.call(max_sleep: 5) do |sleep_for|
28
+ # puts sleep_for # => 2
29
+ # end
30
+ class ReaperThrottle
31
+ def initialize(initial_sleep: )
32
+ @initial_sleep = initial_sleep
33
+ @sleep_for = @initial_sleep
34
+ end
35
+
36
+ def call(max_sleep: )
37
+ raise "Must call with a block" unless block_given?
38
+
39
+ sleep_for = [@sleep_for, max_sleep].min
40
+
41
+ yield sleep_for
42
+
43
+ if sleep_for < @sleep_for
44
+ reset!
45
+ else
46
+ @sleep_for *= 2
47
+ end
48
+ end
49
+
50
+ def reset!
51
+ @sleep_for = @initial_sleep
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,71 @@
1
+ module Hatchet
2
+ # A class for throttling non-http resources
3
+ #
4
+ # Non-http calls can be rate-limited for example shell calls to `heroku run ` and `git push heroku`
5
+ # this class provides an easy interface to leverage the rate throttling behavior baked into `PlatformAPI`
6
+ # for calls things that do not have a real associated web request
7
+ #
8
+ # Example:
9
+ #
10
+ # output = ""
11
+ # ShellThrottle.new(platform_api: @platform_api).call
12
+ # output = `git push heroku main`
13
+ # throw(:throttle) if output.match?(/reached the API rate limit/)
14
+ # end
15
+ # puts output
16
+ #
17
+ # In this example `git push heroku main` will retry and backoff until the output no longer matches `reached the API rate limit`.
18
+ #
19
+ class ShellThrottle
20
+ def initialize(platform_api: )
21
+ @platform_api = platform_api
22
+ end
23
+
24
+ def call
25
+ out = nil
26
+ PlatformAPI.rate_throttle.call do
27
+ catch(:throttle) do
28
+ out = yield
29
+ return
30
+ end
31
+
32
+ try_again
33
+ end
34
+ return out
35
+ end
36
+
37
+ private def success
38
+ FakeResponse.new(status: 200, remaining: remaining)
39
+ end
40
+
41
+ private def try_again
42
+ FakeResponse.new(status: 429, remaining: remaining)
43
+ end
44
+
45
+ private def remaining
46
+ @platform_api.rate_limit.info["remaining"]
47
+ end
48
+
49
+
50
+ # Helper class to be used along with the PlatformAPI.rate_throttle interface
51
+ # that expects a response object
52
+ #
53
+ # Example:
54
+ #
55
+ # FakeResponse.new(status: 200, remaining: 2).status #=> 200
56
+ # FakeResponse.new(status: 200, remaining: 2).headers["RateLimit-Remaining"] #=> 2
57
+ class FakeResponse
58
+ attr_reader :status, :headers
59
+
60
+ def initialize(status:, remaining: )
61
+ @status = status
62
+
63
+ @headers = {
64
+ "RateLimit-Remaining" => remaining,
65
+ "RateLimit-Multiplier" => 1,
66
+ "Content-Type" => "text/plain".freeze
67
+ }
68
+ end
69
+ end
70
+ end
71
+ end