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