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.
- checksums.yaml +4 -4
- data/.circleci/config.yml +2 -0
- data/CHANGELOG.md +16 -1
- data/README.md +772 -174
- data/bin/hatchet +4 -2
- data/hatchet.gemspec +1 -2
- data/hatchet.json +2 -1
- data/hatchet.lock +2 -0
- data/lib/hatchet.rb +1 -2
- data/lib/hatchet/api_rate_limit.rb +6 -17
- data/lib/hatchet/app.rb +137 -30
- data/lib/hatchet/config.rb +1 -1
- data/lib/hatchet/git_app.rb +27 -1
- data/lib/hatchet/reaper.rb +159 -56
- data/lib/hatchet/reaper/app_age.rb +49 -0
- data/lib/hatchet/reaper/reaper_throttle.rb +55 -0
- data/lib/hatchet/shell_throttle.rb +71 -0
- data/lib/hatchet/test_run.rb +2 -1
- data/lib/hatchet/version.rb +1 -1
- data/spec/hatchet/allow_failure_git_spec.rb +27 -2
- data/spec/hatchet/app_spec.rb +145 -6
- data/spec/hatchet/ci_spec.rb +10 -1
- data/spec/hatchet/lock_spec.rb +12 -1
- data/spec/unit/reaper_spec.rb +153 -0
- data/spec/unit/shell_throttle.rb +28 -0
- metadata +16 -23
data/bin/hatchet
CHANGED
@@ -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]
|
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}
|
162
|
+
cmd("cd #{path} && git pull --rebase #{git_repo} --quiet")
|
161
163
|
end
|
162
164
|
|
163
165
|
def clone(path, git_repo, quiet: true)
|
data/hatchet.gemspec
CHANGED
@@ -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
|
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"
|
data/hatchet.json
CHANGED
data/hatchet.lock
CHANGED
@@ -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
|
data/lib/hatchet.rb
CHANGED
@@ -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
|
-
|
10
|
+
APP_PREFIX = (ENV['HATCHET_APP_PREFIX'] || "hatchet-t-")
|
12
11
|
end
|
13
12
|
|
14
13
|
require 'hatchet/version'
|
@@ -1,14 +1,11 @@
|
|
1
|
-
#
|
1
|
+
# Legacy class
|
2
2
|
#
|
3
|
-
#
|
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
|
|
data/lib/hatchet/app.rb
CHANGED
@@ -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
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
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
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
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
|
-
|
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
|
-
|
223
|
-
|
224
|
-
|
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
|
-
|
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'
|
data/lib/hatchet/config.rb
CHANGED
@@ -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]
|
49
|
+
!Dir[path]&.empty?
|
50
50
|
end
|
51
51
|
raise BadRepoName.new(name, possible_paths) if path.nil? || path.empty?
|
52
52
|
path
|
data/lib/hatchet/git_app.rb
CHANGED
@@ -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
|
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
|
data/lib/hatchet/reaper.rb
CHANGED
@@ -1,88 +1,191 @@
|
|
1
1
|
require 'tmpdir'
|
2
2
|
|
3
3
|
module Hatchet
|
4
|
-
#
|
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
|
-
|
11
|
-
|
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
|
-
|
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
|
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
|
-
|
21
|
-
|
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
|
28
|
-
|
29
|
-
|
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
|
-
|
81
|
+
# No guardrails, will delete all apps that match the hatchet namespace
|
82
|
+
def destroy_all
|
34
83
|
get_apps
|
35
84
|
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
#
|
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
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
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
|
-
|
59
|
-
|
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
|
-
|
65
|
-
|
66
|
-
|
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
|
70
|
-
|
71
|
-
|
72
|
-
|
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
|
77
|
-
|
78
|
-
|
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
|
-
|
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
|
85
|
-
@
|
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"
|