heroku_hatchet 7.4.0 → 8.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +15 -22
- data/bin/hatchet +14 -7
- data/lib/hatchet/app.rb +11 -14
- data/lib/hatchet/reaper.rb +52 -109
- data/lib/hatchet/version.rb +1 -1
- data/lib/hatchet.rb +31 -8
- data/spec/hatchet/app_spec.rb +5 -38
- data/spec/hatchet/ci_spec.rb +0 -8
- data/spec/unit/default_ci_branch_spec.rb +27 -0
- data/spec/unit/reaper_spec.rb +12 -55
- metadata +4 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9e66d5aea4901cc03c37bbf6d0f4b567e49f9c170d471ff461cc642956a195e9
|
4
|
+
data.tar.gz: 79fb324886b6a90828a753a9e93679db0386d1696f081efc33b05fef8d2fd5ab
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d337d285c3c35fc705e2f715bf209df425a1d36e908cba200610123b41eab488a59439109634c2456c148d6f877d93e9910becfde2606582803853bd9bce5913
|
7
|
+
data.tar.gz: 9c601a90d90d82782fd4fcdfca05ca1030738a1a0e93bd73c0cc42c3f60b91b7094b49b9128a3f4358ad4d161aceb2206850f90f155f2a1973529e705a00e345
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,14 @@
|
|
1
1
|
## HEAD
|
2
2
|
|
3
|
+
## 8.0.0
|
4
|
+
|
5
|
+
- Breaking change: Delete apps on teardown. Previously hatchet would delete apps lazily to help with debugging. This behavior allowed developers to inspect logs and `heroku run bash` in the event of an unexpected failure. In practice, it is rarely needed and causes accounts to retain apps indefinitely. Previously there was no cost to retaining applications, but now `basic` applications incur a charge. Change details:
|
6
|
+
- The application teardown process now deletes applications directly.
|
7
|
+
- To skip destroying applications on teardown, set `HEROKU_DEBUG_EXPENSIVE=1`. This env var will cause `App#teardown!` to skip deletion so you can introspect why one failed.
|
8
|
+
- When hatchet needs a new application, it will first delete all applications that are created at least `HATCHET_ALIVE_TTL_MINUTES` ago. If it cannot delete any applications and the account is already at `HATCHET_APP_LIMIT`, it will sleep and try again later.
|
9
|
+
- Introduce `--older-than` flag to `hatchet destroy` CLI command. For example, the command `hatchet destroy --older-than=7`will remove any apps older than the provided value (in minutes).
|
10
|
+
- Add support for GitHub Actions env vars (https://github.com/heroku/hatchet/pull/189)
|
11
|
+
|
3
12
|
## 7.4.0
|
4
13
|
|
5
14
|
- Supports "basic" scaledown (https://github.com/heroku/hatchet/pull/193)
|
data/README.md
CHANGED
@@ -266,16 +266,17 @@ Hatchet::Runner.new("minimal_webpacker", buildpacks: buildpacks).deploy do |app,
|
|
266
266
|
expect($?.exitstatus).to eq(0)
|
267
267
|
expect($?.success?).to be_truthy
|
268
268
|
|
269
|
-
# In Ruby all objects except `nil` and `false` are "truthy"
|
270
|
-
#
|
269
|
+
# In Ruby all objects except `nil` and `false` are "truthy"
|
270
|
+
# in this case it could also be tested using `be_true` but
|
271
|
+
# it's best practice to use this `be_truthy` test helper instead
|
271
272
|
end
|
272
273
|
```
|
273
274
|
|
274
|
-
You can disable
|
275
|
+
You can disable error on exit status behavior, [see how to do it in the reference tests](https://github.com/heroku/hatchet/blob/master/spec/hatchet/app_spec.rb)
|
275
276
|
|
276
277
|
- **Escaping and raw mode:**
|
277
278
|
|
278
|
-
By default `app.run()` will escape the input so you can safely call `app.run("cmd && cmd")` and it works as expected. But if you want to do something custom, you can enable raw mode by passing in `raw: true` [see how to do it in the reference tests](https://github.com/heroku/hatchet/blob/master/spec/hatchet/app_spec.rb)
|
279
|
+
By default `app.run()` will escape the input so you can safely call `app.run("cmd && cmd")` and it works as expected. But if you want to do something custom, you can enable raw mode by passing in `raw: true` [see how to do it in the reference tests](https://github.com/heroku/hatchet/blob/master/spec/hatchet/app_spec.rb). If you do that, you'll need to ensure your inputs are propperly shell escaped.
|
279
280
|
|
280
281
|
- **Heroku options:**
|
281
282
|
|
@@ -358,21 +359,9 @@ And later:
|
|
358
359
|
Destroying "hatchet-t-fd25e3626b". Hatchet app limit: 80
|
359
360
|
```
|
360
361
|
|
361
|
-
|
362
|
+
If an app is not deleted for some reason, it will be deleted by a future test run. Applications not deleted by calling `teardown` will be allowed to live for `HATCHET_ALIVE_TTL_MINUTES`. This behavior allows multiple test runs on the same hatchet account without one test run deleting apps that another one still needs.
|
362
363
|
|
363
|
-
|
364
|
-
$ heroku run bash -a hatchet-t-bed73940a6
|
365
|
-
```
|
366
|
-
|
367
|
-
And use that to debug. Hatchet deletes old apps on demand. You tell it what your limits are and it will stay within those limits:
|
368
|
-
|
369
|
-
```
|
370
|
-
HATCHET_APP_LIMIT=20
|
371
|
-
```
|
372
|
-
|
373
|
-
With these env vars, Hatchet will "reap" older hatchet apps when it sees there are 20 or more hatchet apps. For CI, it's recommended that you increase the `HATCHET_APP_LIMIT` to 80-100. Hatchet will mark apps as safe for deletion once they've finished, and the `teardown!` method has been called on them (it tracks this by enabling maintenance mode on apps). Hatchet only tracks its apps. Hatchet uses a regex pattern on the name of apps to see which ones it can manage. If your account has reached the maximum number of global Heroku apps, you'll need to remove some manually.
|
374
|
-
|
375
|
-
If an app is not marked as being in maintenance mode for some reason, it can be deleted, but only after it has been allowed to live for some time. This behavior is configured by the `HATCHET_ALIVE_TTL_MINUTES` env var. For example, if you set it for `7`, Hatchet will ensure that any apps that are not marked as being in maintenance mode are allowed to live for at least seven minutes. This should give the app time to finish the test's execution, so it is not deleted mid-deploy. When this deletion happens, you'll see a warning in your output. It could indicate you're not properly cleaning up and calling `teardown!` on some of your apps, or it could mean that you're attempting to execute more tests concurrently than your `HATCHET_APP_LIMIT` allows. This deletion-mid-test behavior might otherwise be triggered if you have multiple CI runs executing at the same time.
|
364
|
+
For example, if you set `HATCHET_ALIVE_TTL_MINUTES=7`, Hatchet will ensure that any apps that are older than 7 minutes will be deleted. If all of your tests finish in under 7 minutes then apps won't be deleted mid-deploy. When apps are deleted, you'll see a warning in your output. It could indicate you're not properly cleaning up and calling `teardown!` on some of your apps, or it could mean that you're attempting to execute more tests concurrently than your `HATCHET_APP_LIMIT` allows. Or that something else prevented apps from getting deleted on teardown, such as a Heroku API outage.
|
376
365
|
|
377
366
|
It's recommended you don't use your personal Heroku API key for running tests on a CI server since the hatchet apps count against your account maximum limits. Running tests using your account locally is fine for debugging one or two tests.
|
378
367
|
|
@@ -684,7 +673,9 @@ end
|
|
684
673
|
- `app.platform_api`: Returns an instance of the [platform-api Heroku client](https://github.com/heroku/platform-api). If Hatchet doesn't give you access to a part of Heroku that you need, you can likely do it with the platform-api client.
|
685
674
|
- `app.push!`: Push code to your Heroku app. It can be used inside of a `deploy` block to re-deploy.
|
686
675
|
- `app.run_ci`: Runs Heroku CI against the app returns a TestRun object in the block
|
687
|
-
- `app.teardown!`: This method is called automatically when using `app.deploy` in block mode after the deploy block finishes. When called it will
|
676
|
+
- `app.teardown!`: This method is called automatically when using `app.deploy` in block mode after the deploy block finishes. When called it will delete the application.
|
677
|
+
|
678
|
+
Here is an example of a test that creates and deploys an app manually (without a block), then later tears it down manually. If you deploy an application without calling `teardown!` then hatchet will leak apps.
|
688
679
|
|
689
680
|
```ruby
|
690
681
|
before(:each) do
|
@@ -693,7 +684,7 @@ before(:each) do
|
|
693
684
|
end
|
694
685
|
|
695
686
|
after(:each) do
|
696
|
-
@app
|
687
|
+
@app&.teardown!
|
697
688
|
end
|
698
689
|
|
699
690
|
it "uses ruby" do
|
@@ -718,6 +709,7 @@ HATCHET_ALIVE_TTL_MINUTES=7
|
|
718
709
|
|
719
710
|
# HATCHET_RUN_MULTI=1 # WARNING: Setting this env var will incur charges against your account. To use this env var you must also enable `HATCHET_EXPENSIVE_MODE`
|
720
711
|
# HATCHET_EXPENSIVE_MODE=1 # WARNING: Do not set this environment variable unless you're okay with possibly large bills
|
712
|
+
# HEROKU_DEBUG_EXPENSIVE=1 # WARNING: This will prevent apps from being cleaned up automatically and you will be billed for any apps left running
|
721
713
|
```
|
722
714
|
|
723
715
|
> The syntax to set an env var in Ruby is `ENV["HATCHET_RETRIES"] = "2"` all env vars are strings.
|
@@ -725,12 +717,13 @@ HATCHET_ALIVE_TTL_MINUTES=7
|
|
725
717
|
- `HATCHET_BUILDPACK_BASE`: This is the URL where Hatchet can find your buildpack. It must be public for Heroku to be able to use your buildpack.
|
726
718
|
- `HATCHET_BUILDPACK_BRANCH`: By default, Hatchet will use your current git branch name. If, for some reason, git is not available or you want to manually specify it like `ENV["HATCHET_BUILDPACK_BRANCH'] = ENV[`MY_CI_BRANCH`]` then you can.
|
727
719
|
- `HATCHET_RETRIES` If the `ENV['HATCHET_RETRIES']` is set to a number, deploys are expected to work and automatically retry that number of times. Due to testing using a network and random failures, setting this value to `3` retries seems to work well. If an app cannot be deployed within its allotted number of retries, an error will be raised. The downside of a larger number is that your suite will keep running for much longer when there are legitimate failures.
|
728
|
-
- `HATCHET_APP_LIMIT`: The maximum number of
|
720
|
+
- `HATCHET_APP_LIMIT`: The maximum number of applications that hatchet is allowed to utilize on your account. If you hit this limit mid-test then Hatchet will need to wait until other tests finish and delete their apps. Keep in mind that you may have several concurrent CI executions on the same account competing for the same limited number of apps.
|
729
721
|
- `HEROKU_API_KEY`: The API key of your test account user. If you run locally without this set, it will use your personal credentials.
|
730
722
|
- `HEROKU_API_USER`: The email address of your user account. If you run locally without this set, it will use your personal credentials.
|
731
723
|
- `HATCHET_DEFAULT_STACK`: The default Heroku stack to be used when an explicit `stack` is not passed to `App.new`. If this is not set, apps will instead use Heroku platform's [default stack](https://devcenter.heroku.com/articles/stack#default-stack).
|
732
724
|
- `HATCHET_RUN_MULTI`: If enabled, this will scale up deployed apps to "standard-1x" once deployed instead of running on the free tier. This enables the `run_multi` method capability, however scaling up is not free. WARNING: Setting this env var will incur charges to your Heroku account. We recommended never to enable this setting unless you work for Heroku. To use this you must also set `HATCHET_EXPENSIVE_MODE=1`
|
733
725
|
- `HATCHET_EXPENSIVE_MODE`: This is intended to be a "safety" environment variable. If it is not set, Hatchet will prevent you from using the `run_multi: true` setting or the `HATCHET_RUN_MULTI` environment variables. There are still ways to incur charges without this feature, but unless you're absolutely confident your test setup will not leave "orphan" apps that are billing you, do not enable this setting. Even then, only set this value if you work for Heroku. To recap WARNING: setting this is expensive.
|
726
|
+
- `HEROKU_DEBUG_EXPENSIVE`: If set, hatchet will not delete applications on teardown. This can be used to introspect build logs or run `heroku run bash` on the application created for the failed test. Note that if another hatchet process runs after `HATCHET_ALIVE_TTL_MINUTES` then it will delete your applications.
|
734
727
|
|
735
728
|
## Basic
|
736
729
|
|
@@ -1037,7 +1030,7 @@ Ruby is full of multitudes, this isn't even close to being exhaustive, just enou
|
|
1037
1030
|
Hatchet has a CLI for installing and maintaining external repos you're
|
1038
1031
|
using to test against. If you have Hatchet installed as a gem run
|
1039
1032
|
|
1040
|
-
$
|
1033
|
+
$ hatchet --help
|
1041
1034
|
|
1042
1035
|
For more info on commands. If you're using the source code you can run
|
1043
1036
|
the command by going to the source code directory and running:
|
data/bin/hatchet
CHANGED
@@ -106,20 +106,27 @@ class HatchetCLI < Thor
|
|
106
106
|
end
|
107
107
|
end
|
108
108
|
|
109
|
-
desc "destroy
|
110
|
-
option :all, :
|
111
|
-
|
109
|
+
desc "destroy", "Deletes application(s)"
|
110
|
+
option :all, type: :boolean, desc: "Delete ALL hatchet apps"
|
111
|
+
option :older_than, type: :numeric, desc: "Delete all hatchet apps older than N minutes"
|
112
|
+
def destroy
|
112
113
|
api_key = ENV['HEROKU_API_KEY'] || `heroku auth:token`.chomp
|
113
114
|
platform_api = PlatformAPI.connect_oauth(api_key, cache: Moneta.new(:Null))
|
114
115
|
api_rate_limit = ApiRateLimit.new(platform_api)
|
115
116
|
reaper = Hatchet::Reaper.new(api_rate_limit: api_rate_limit)
|
116
117
|
|
117
|
-
|
118
|
+
case
|
119
|
+
when options[:all]
|
120
|
+
puts "Destroying ALL apps"
|
118
121
|
reaper.destroy_all
|
119
|
-
|
120
|
-
|
122
|
+
puts "Done"
|
123
|
+
when options[:older_than]
|
124
|
+
minutes = options[:older_than].to_i
|
125
|
+
puts "Destroying apps older than #{minutes}m"
|
126
|
+
reaper.destroy_older_apps(minutes: minutes)
|
127
|
+
puts "Done"
|
121
128
|
else
|
122
|
-
raise "
|
129
|
+
raise "No flags given run `hatchet help destroy` for options"
|
123
130
|
end
|
124
131
|
end
|
125
132
|
|
data/lib/hatchet/app.rb
CHANGED
@@ -11,7 +11,7 @@ module Hatchet
|
|
11
11
|
"https://github.com/heroku/heroku-buildpack-ruby.git"
|
12
12
|
}
|
13
13
|
}
|
14
|
-
HATCHET_BUILDPACK_BRANCH = -> { ENV['HATCHET_BUILDPACK_BRANCH'] ||
|
14
|
+
HATCHET_BUILDPACK_BRANCH = -> { ENV['HATCHET_BUILDPACK_BRANCH'] || Hatchet.git_branch }
|
15
15
|
|
16
16
|
attr_reader :name, :stack, :repo_name, :app_config, :buildpacks, :reaper, :max_retries_count
|
17
17
|
|
@@ -67,6 +67,7 @@ module Hatchet
|
|
67
67
|
@repo_name = repo_name
|
68
68
|
@directory = self.config.path_for_name(@repo_name)
|
69
69
|
@name = name
|
70
|
+
@heroku_id = nil
|
70
71
|
@stack = stack
|
71
72
|
@debug = debug || debugging
|
72
73
|
@allow_failure = allow_failure
|
@@ -82,7 +83,6 @@ module Hatchet
|
|
82
83
|
end
|
83
84
|
@run_multi_array = []
|
84
85
|
@already_in_dir = nil
|
85
|
-
@app_is_setup = nil
|
86
86
|
|
87
87
|
@before_deploy_array = []
|
88
88
|
@before_deploy_array << before_deploy if before_deploy
|
@@ -280,11 +280,14 @@ module Hatchet
|
|
280
280
|
def create_app
|
281
281
|
3.times.retry do
|
282
282
|
begin
|
283
|
+
@reaper.destroy_older_apps
|
283
284
|
hash = { name: name, stack: stack }
|
284
285
|
hash.delete_if { |k,v| v.nil? }
|
285
|
-
heroku_api_create_app(hash)
|
286
|
+
result = heroku_api_create_app(hash)
|
287
|
+
@heroku_id = result["id"]
|
286
288
|
rescue => e
|
287
|
-
|
289
|
+
puts "Warning: Could not create app #{e.message}"
|
290
|
+
@reaper.clean_old_or_sleep
|
288
291
|
raise e
|
289
292
|
end
|
290
293
|
end
|
@@ -301,7 +304,7 @@ module Hatchet
|
|
301
304
|
|
302
305
|
# creates a new heroku app via the API
|
303
306
|
def setup!
|
304
|
-
return self if @
|
307
|
+
return self if @heroku_id
|
305
308
|
puts "Hatchet setup: #{name.inspect} for #{repo_name.inspect}"
|
306
309
|
create_app
|
307
310
|
set_labs!
|
@@ -309,7 +312,6 @@ module Hatchet
|
|
309
312
|
api_rate_limit.call.buildpack_installation.update(name, updates: buildpack_list)
|
310
313
|
set_config @app_config
|
311
314
|
|
312
|
-
@app_is_setup = true
|
313
315
|
self
|
314
316
|
end
|
315
317
|
alias :setup :setup!
|
@@ -356,16 +358,11 @@ module Hatchet
|
|
356
358
|
end
|
357
359
|
|
358
360
|
def teardown!
|
359
|
-
return false unless @app_is_setup
|
360
|
-
|
361
|
-
if @run_multi_is_setup
|
362
361
|
@run_multi_array.map(&:join)
|
363
|
-
platform_api.formation.update(name, "web", {"size" => "basic"})
|
364
|
-
end
|
365
|
-
|
366
362
|
ensure
|
367
|
-
@
|
368
|
-
|
363
|
+
if @heroku_id && !ENV["HEROKU_DEBUG_EXPENSIVE"]
|
364
|
+
@reaper.destroy_with_log(name: @name, id: @heroku_id, reason: "teardown")
|
365
|
+
end
|
369
366
|
end
|
370
367
|
|
371
368
|
def in_directory
|
data/lib/hatchet/reaper.rb
CHANGED
@@ -1,167 +1,114 @@
|
|
1
1
|
require 'tmpdir'
|
2
2
|
|
3
3
|
module Hatchet
|
4
|
-
#
|
4
|
+
# Delete apps
|
5
5
|
#
|
6
|
-
#
|
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.
|
6
|
+
# Delete a single app:
|
9
7
|
#
|
10
|
-
#
|
11
|
-
# on maintenance mode. The reaper will delete these in order (oldest first).
|
8
|
+
# @reaper.destroy_with_log(id: id, name: name, reason: "console")
|
12
9
|
#
|
13
|
-
#
|
14
|
-
#
|
15
|
-
#
|
16
|
-
#
|
10
|
+
# Clear out all apps older than HATCHET_ALIVE_TTL_MINUTES:
|
11
|
+
#
|
12
|
+
# @reaper.destroy_older_apps
|
13
|
+
#
|
14
|
+
# If you need to clear up space or wait for space to be cleared
|
15
|
+
# up then:
|
16
|
+
#
|
17
|
+
# @reaper.clean_old_or_sleep
|
17
18
|
#
|
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
19
|
#
|
23
20
|
# Notes:
|
24
21
|
#
|
25
22
|
# - The class uses a file mutex so that multiple processes on the same machine do not attempt to run the
|
26
23
|
# 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.
|
29
24
|
class Reaper
|
30
25
|
class AlreadyDeletedError < StandardError; end
|
31
26
|
|
32
27
|
HATCHET_APP_LIMIT = Integer(ENV["HATCHET_APP_LIMIT"] || 20) # the number of apps hatchet keeps around
|
33
28
|
DEFAULT_REGEX = /^#{Regexp.escape(Hatchet::APP_PREFIX)}[a-f0-9]+/
|
29
|
+
TTL_MINUTES = ENV.fetch("HATCHET_ALIVE_TTL_MINUTES", "7").to_i
|
34
30
|
|
35
31
|
attr_accessor :io, :hatchet_app_limit
|
36
32
|
|
37
33
|
def initialize(api_rate_limit: , regex: DEFAULT_REGEX, io: STDOUT, hatchet_app_limit: HATCHET_APP_LIMIT, initial_sleep: 10)
|
38
|
-
@api_rate_limit = api_rate_limit
|
39
|
-
@regex = regex
|
40
34
|
@io = io
|
41
|
-
@
|
42
|
-
@
|
43
|
-
@
|
44
|
-
@
|
35
|
+
@apps = []
|
36
|
+
@regex = regex
|
37
|
+
@limit = hatchet_app_limit
|
38
|
+
@api_rate_limit = api_rate_limit
|
45
39
|
@reaper_throttle = ReaperThrottle.new(initial_sleep: initial_sleep)
|
46
40
|
end
|
47
41
|
|
48
|
-
|
42
|
+
# Called when we need an app, but are over limit or
|
43
|
+
# if an exception has occured that was possibly triggered
|
44
|
+
# by apps being over limit
|
45
|
+
def clean_old_or_sleep
|
49
46
|
# Protect against parallel deletion of the same app on the same system
|
50
47
|
mutex_file = File.open("#{Dir.tmpdir()}/hatchet_reaper_mutex", File::CREAT)
|
51
48
|
mutex_file.flock(File::LOCK_EX)
|
52
49
|
|
53
|
-
|
50
|
+
destroy_older_apps(force_refresh: true)
|
54
51
|
|
55
|
-
|
56
|
-
|
57
|
-
|
52
|
+
if @apps.length > @limit
|
53
|
+
age = AppAge.new(created_at: @apps.last["created_at"], ttl_minutes: TTL_MINUTES)
|
54
|
+
@reaper_throttle.call(max_sleep: age.sleep_for_ttl) do |sleep_for|
|
58
55
|
io.puts <<-EOM.strip_heredoc
|
59
|
-
WARNING:
|
60
|
-
#{
|
61
|
-
Exception: #{app_exception_message}
|
56
|
+
WARNING: Hatchet app limit reached (#{@apps.length}/#{@limit})
|
57
|
+
All known apps are younger than #{TTL_MINUTES} minutes
|
62
58
|
EOM
|
63
|
-
reap_once
|
64
|
-
end
|
65
59
|
|
66
|
-
|
67
|
-
|
60
|
+
sleep(sleep_for)
|
61
|
+
end
|
68
62
|
end
|
69
63
|
ensure
|
70
64
|
mutex_file.close
|
71
65
|
end
|
72
66
|
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
67
|
+
# Destroys apps that are older than the given argument (expecting integer minutes)
|
68
|
+
def destroy_older_apps(minutes: TTL_MINUTES, force_refresh: @apps.empty?)
|
69
|
+
refresh_app_list if force_refresh
|
70
|
+
|
71
|
+
@apps.each do |app|
|
72
|
+
age = AppAge.new(created_at: app["created_at"], ttl_minutes: minutes)
|
73
|
+
if age.can_delete?
|
74
|
+
destroy_with_log(
|
75
|
+
name: app["name"],
|
76
|
+
id: app["id"],
|
77
|
+
reason: "app age (#{age.in_minutes}m) is older than #{minutes}m"
|
78
|
+
)
|
79
|
+
end
|
80
|
+
rescue AlreadyDeletedError
|
81
|
+
# Ignore, keep going
|
82
|
+
end
|
79
83
|
end
|
80
84
|
|
81
85
|
# No guardrails, will delete all apps that match the hatchet namespace
|
82
|
-
def destroy_all
|
83
|
-
refresh_app_list
|
86
|
+
def destroy_all(force_refresh: @apps.empty?)
|
87
|
+
refresh_app_list if force_refresh
|
84
88
|
|
85
|
-
|
89
|
+
@apps.each do |app|
|
86
90
|
begin
|
87
|
-
destroy_with_log(name: app["name"], id: app["id"])
|
91
|
+
destroy_with_log(name: app["name"], id: app["id"], reason: "destroy all")
|
88
92
|
rescue AlreadyDeletedError
|
89
93
|
# Ignore, keep going
|
90
94
|
end
|
91
95
|
end
|
92
96
|
end
|
93
97
|
|
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)
|
101
|
-
end
|
102
|
-
rescue AlreadyDeletedError
|
103
|
-
retry
|
104
|
-
end
|
105
|
-
|
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.strip_heredoc
|
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
|
136
|
-
end
|
137
|
-
|
138
98
|
private def get_heroku_apps
|
139
99
|
@api_rate_limit.call.app.list
|
140
100
|
end
|
141
101
|
|
142
102
|
private def refresh_app_list
|
143
|
-
apps = get_heroku_apps.
|
103
|
+
@apps = get_heroku_apps.
|
104
|
+
filter {|app| app["name"].match(@regex) }.
|
144
105
|
map {|app| app["created_at"] = DateTime.parse(app["created_at"].to_s); app }.
|
145
106
|
sort_by { |app| app["created_at"] }.
|
146
107
|
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
|
160
|
-
end
|
161
108
|
end
|
162
109
|
|
163
|
-
|
164
|
-
message = "Destroying #{name.inspect}: #{id}, #{
|
110
|
+
def destroy_with_log(name:, id:, reason: )
|
111
|
+
message = "Destroying #{name.inspect}: #{id}, (#{@apps.length}/#{@limit}) reason: #{reason}"
|
165
112
|
|
166
113
|
@api_rate_limit.call.app.delete(id)
|
167
114
|
|
@@ -180,10 +127,6 @@ module Hatchet
|
|
180
127
|
io.puts "Duplicate destroy attempted #{name.inspect}: #{id}, status: 403, request_id: #{request_id}"
|
181
128
|
raise AlreadyDeletedError.new
|
182
129
|
end
|
183
|
-
|
184
|
-
private def hatchet_app_count
|
185
|
-
@finished_hatchet_apps.length + @unfinished_hatchet_apps.length
|
186
|
-
end
|
187
130
|
end
|
188
131
|
end
|
189
132
|
|
data/lib/hatchet/version.rb
CHANGED
data/lib/hatchet.rb
CHANGED
@@ -21,20 +21,43 @@ require 'hatchet/api_rate_limit'
|
|
21
21
|
require 'hatchet/init_project'
|
22
22
|
require 'hatchet/heroku_run'
|
23
23
|
|
24
|
+
class DefaultCIBranch
|
25
|
+
def initialize(env: ENV)
|
26
|
+
@env = env
|
27
|
+
end
|
28
|
+
|
29
|
+
def call
|
30
|
+
# https://circleci.com/docs/variables
|
31
|
+
return @env['CIRCLE_BRANCH'] if @env['CIRCLE_BRANCH']
|
32
|
+
# https://docs.github.com/en/actions/learn-github-actions/environment-variables
|
33
|
+
# GITHUB_HEAD_REF is provided for PRs, but blank for branch actions.
|
34
|
+
return @env['GITHUB_HEAD_REF'] if @env['GITHUB_HEAD_REF'] && !@env['GITHUB_HEAD_REF']&.empty?
|
35
|
+
# GITHUB_REF_NAME is incorrect on PRs (`1371/merge`), but correct for branch actions.
|
36
|
+
return @env['GITHUB_REF_NAME'] if @env['GITHUB_REF_NAME']
|
37
|
+
# https://devcenter.heroku.com/articles/heroku-ci#immutable-environment-variables
|
38
|
+
return @env['HEROKU_TEST_RUN_BRANCH'] if @env['HEROKU_TEST_RUN_BRANCH']
|
39
|
+
# TRAVIS_BRANCH works fine unless the build is a pull-request. In that case, it will contain the target branch
|
40
|
+
# not the actual pull-request branch! TRAVIS_PULL_REQUEST_BRANCH contains the correct branch but will be empty
|
41
|
+
# for push builds. See: https://docs.travis-ci.com/user/environment-variables/
|
42
|
+
return @env['TRAVIS_PULL_REQUEST_BRANCH'] if @env['TRAVIS_PULL_REQUEST_BRANCH'] && !@env['TRAVIS_PULL_REQUEST_BRANCH']&.empty?
|
43
|
+
return @env['TRAVIS_BRANCH'] if @env['TRAVIS_BRANCH']
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
24
47
|
module Hatchet
|
25
48
|
RETRIES = Integer(ENV['HATCHET_RETRIES'] || 1)
|
26
49
|
Runner = Hatchet::GitApp
|
27
50
|
|
28
51
|
def self.git_branch
|
29
|
-
|
30
|
-
# not the actual pull-request branch! TRAVIS_PULL_REQUEST_BRANCH contains the correct branch but will be empty
|
31
|
-
# for push builds. See: https://docs.travis-ci.com/user/environment-variables/
|
32
|
-
return ENV['TRAVIS_PULL_REQUEST_BRANCH'] if ENV['TRAVIS_PULL_REQUEST_BRANCH'] && !ENV['TRAVIS_PULL_REQUEST_BRANCH'].empty?
|
33
|
-
return ENV['TRAVIS_BRANCH'] if ENV['TRAVIS_BRANCH']
|
52
|
+
branch = DefaultCIBranch.new.call
|
34
53
|
|
35
|
-
|
36
|
-
|
37
|
-
|
54
|
+
if branch
|
55
|
+
branch
|
56
|
+
else
|
57
|
+
out = `git rev-parse --abbrev-ref HEAD`.strip
|
58
|
+
raise "Attempting to find current branch name. Error: Cannot describe git: #{out}" unless $?.success?
|
59
|
+
out
|
60
|
+
end
|
38
61
|
end
|
39
62
|
|
40
63
|
if ENV["HATCHET_DEBUG_DEADLOCK"]
|
data/spec/hatchet/app_spec.rb
CHANGED
@@ -43,14 +43,14 @@ describe "AppTest" do
|
|
43
43
|
|
44
44
|
reaper = app.reaper
|
45
45
|
|
46
|
-
def reaper.
|
47
|
-
def reaper.
|
46
|
+
def reaper.clean_old_or_sleep; @app_exception_message = true; end
|
47
|
+
def reaper.clean_old_was_called?; @app_exception_message; end
|
48
48
|
|
49
49
|
expect {
|
50
50
|
app.create_app
|
51
51
|
}.to raise_error("made you look")
|
52
52
|
|
53
|
-
expect(reaper.
|
53
|
+
expect(reaper.clean_old_was_called?).to be_truthy
|
54
54
|
end
|
55
55
|
|
56
56
|
it "app with default" do
|
@@ -82,37 +82,6 @@ describe "AppTest" do
|
|
82
82
|
end
|
83
83
|
end
|
84
84
|
|
85
|
-
it "marks itself 'finished' when done in block mode" do
|
86
|
-
app = Hatchet::Runner.new("default_ruby")
|
87
|
-
|
88
|
-
def app.push_with_retry!; nil; end
|
89
|
-
app.deploy do |app|
|
90
|
-
expect(app.platform_api.app.info(app.name)["maintenance"]).to be_falsey
|
91
|
-
end
|
92
|
-
|
93
|
-
# After the app is updated, there's no guarantee it will still exist
|
94
|
-
# so we cannot rely on an api call to determine maintenance mode
|
95
|
-
app_update_info = app.instance_variable_get(:"@app_update_info")
|
96
|
-
expect(app_update_info["name"]).to eq(app.name)
|
97
|
-
expect(app_update_info["maintenance"]).to be_truthy
|
98
|
-
end
|
99
|
-
|
100
|
-
it "marks itself 'finished' when done in non-block mode" do
|
101
|
-
app = Hatchet::Runner.new("default_ruby")
|
102
|
-
|
103
|
-
def app.push_with_retry!; nil; end
|
104
|
-
app.deploy
|
105
|
-
expect(app.platform_api.app.info(app.name)["maintenance"]).to be_falsey
|
106
|
-
|
107
|
-
app.teardown!
|
108
|
-
|
109
|
-
# After the app is updated, there's no guarantee it will still exist
|
110
|
-
# so we cannot rely on an api call to determine maintenance mode
|
111
|
-
app_update_info = app.instance_variable_get(:"@app_update_info")
|
112
|
-
expect(app_update_info["name"]).to eq(app.name)
|
113
|
-
expect(app_update_info["maintenance"]).to be_truthy
|
114
|
-
end
|
115
|
-
|
116
85
|
describe "before deploy" do
|
117
86
|
it "dir" do
|
118
87
|
@called = false
|
@@ -263,16 +232,14 @@ describe "AppTest" do
|
|
263
232
|
@run_count = AtomicCount.new(0)
|
264
233
|
app = Hatchet::GitApp.new("default_ruby", run_multi: true)
|
265
234
|
app.deploy do
|
266
|
-
app.run_multi("ls") { |out| expect(out).to include("Gemfile"); @run_count.add(1) }
|
267
|
-
app.run_multi("blerg -v") { |_, status| expect(status.success?).to be_falsey; @run_count.add(1) }
|
235
|
+
app.run_multi("ls") { |out| expect(out).to include("Gemfile"); @run_count.add(1); }
|
236
|
+
app.run_multi("blerg -v") { |_, status| expect(status.success?).to be_falsey; @run_count.add(1); }
|
268
237
|
app.run_multi("ruby -v") do |out, status|
|
269
238
|
expect(out).to include("ruby")
|
270
239
|
expect(status.success?).to be_truthy
|
271
240
|
|
272
241
|
@run_count.add(1)
|
273
242
|
end
|
274
|
-
|
275
|
-
expect(app.platform_api.formation.list(app.name).detect {|ps| ps["type"] == "web"}["size"].downcase).to_not eq("free")
|
276
243
|
end
|
277
244
|
|
278
245
|
# After the deploy block exits `teardown!` is called
|
data/spec/hatchet/ci_spec.rb
CHANGED
@@ -15,15 +15,7 @@ describe "CIFourTest" do
|
|
15
15
|
expect(test_run.output).to match(string)
|
16
16
|
expect(test_run.output).to match("Using rake")
|
17
17
|
expect(test_run.output).to_not match("Installing rake")
|
18
|
-
|
19
|
-
expect(app.platform_api.app.info(app.name)["maintenance"]).to be_falsey
|
20
18
|
end
|
21
|
-
|
22
|
-
# After the app is updated, there's no guarantee it will still exist
|
23
|
-
# so we cannot rely on an api call to determine maintenance mode
|
24
|
-
app_update_info = app.instance_variable_get(:"@app_update_info")
|
25
|
-
expect(app_update_info["name"]).to eq(app.name)
|
26
|
-
expect(app_update_info["maintenance"]).to be_truthy
|
27
19
|
end
|
28
20
|
|
29
21
|
it "error with bad app" do
|
@@ -0,0 +1,27 @@
|
|
1
|
+
|
2
|
+
require "spec_helper"
|
3
|
+
|
4
|
+
describe "DefaultCIBranch" do
|
5
|
+
it "doesn't error on empty env" do
|
6
|
+
out = DefaultCIBranch.new(env: {}).call
|
7
|
+
expect(out).to be_nil
|
8
|
+
end
|
9
|
+
|
10
|
+
it "GitHub PRs" do
|
11
|
+
out = DefaultCIBranch.new(env: {"GITHUB_HEAD_REF" => "iAmaPR"}).call
|
12
|
+
expect(out).to eq("iAmaPR")
|
13
|
+
|
14
|
+
out = DefaultCIBranch.new(env: {"GITHUB_HEAD_REF" => ""}).call
|
15
|
+
expect(out).to be_nil
|
16
|
+
end
|
17
|
+
|
18
|
+
it "GitHub branches" do
|
19
|
+
out = DefaultCIBranch.new(env: {"GITHUB_REF_NAME" => "iAmaBranch"}).call
|
20
|
+
expect(out).to eq("iAmaBranch")
|
21
|
+
end
|
22
|
+
|
23
|
+
it "heroku" do
|
24
|
+
out = DefaultCIBranch.new(env: {"HEROKU_TEST_RUN_BRANCH" => "iAmaBranch"}).call
|
25
|
+
expect(out).to eq("iAmaBranch")
|
26
|
+
end
|
27
|
+
end
|
data/spec/unit/reaper_spec.rb
CHANGED
@@ -29,46 +29,11 @@ describe "Reaper" do
|
|
29
29
|
def reaper.check_get_heroku_apps_called; @called_get_heroku_apps ; end
|
30
30
|
def reaper.reap_once; raise "should not be called"; end
|
31
31
|
|
32
|
-
reaper.
|
32
|
+
reaper.clean_old_or_sleep
|
33
33
|
|
34
34
|
expect(reaper.check_get_heroku_apps_called).to be_truthy
|
35
35
|
end
|
36
36
|
|
37
|
-
it "deletes a maintenance mode app on error" do
|
38
|
-
reaper = Hatchet::Reaper.new(api_rate_limit: Object.new, hatchet_app_limit: 1, io: StringIO.new)
|
39
|
-
|
40
|
-
def reaper.get_heroku_apps
|
41
|
-
@mock_apps ||= [
|
42
|
-
{"name" => "hatchet-t-unfinished", "id" => 2, "maintenance" => false, "created_at" => Time.now.to_s},
|
43
|
-
{"name" => "hatchet-t-foo", "id" => 1, "maintenance" => true, "created_at" => Time.now.to_s}
|
44
|
-
]
|
45
|
-
end
|
46
|
-
def reaper.destroy_with_log(name: , id: )
|
47
|
-
@reaper_destroy_called_with = {"name" => name, "id" => id}
|
48
|
-
end
|
49
|
-
def reaper.destroy_called_with; @reaper_destroy_called_with; end
|
50
|
-
|
51
|
-
reaper.cycle(app_exception_message: true)
|
52
|
-
|
53
|
-
expect(reaper.destroy_called_with).to eq({"name" => "hatchet-t-foo", "id" => 1})
|
54
|
-
end
|
55
|
-
|
56
|
-
it "deletes maintenance mode app when over limit" do
|
57
|
-
reaper = Hatchet::Reaper.new(api_rate_limit: Object.new, hatchet_app_limit: 0, io: StringIO.new)
|
58
|
-
|
59
|
-
def reaper.get_heroku_apps
|
60
|
-
@mock_apps ||= [{"name" => "hatchet-t-foo", "id" => 1, "maintenance" => true, "created_at" => Time.now.to_s}]
|
61
|
-
end
|
62
|
-
def reaper.destroy_with_log(name: , id: )
|
63
|
-
@reaper_destroy_called_with = {"name" => name, "id" => id}
|
64
|
-
end
|
65
|
-
def reaper.destroy_called_with; @reaper_destroy_called_with; end
|
66
|
-
|
67
|
-
reaper.cycle
|
68
|
-
|
69
|
-
expect(reaper.destroy_called_with).to eq({"name" => "hatchet-t-foo", "id" => 1})
|
70
|
-
end
|
71
|
-
|
72
37
|
it "deletes an old app that is past TLL" do
|
73
38
|
reaper = Hatchet::Reaper.new(api_rate_limit: Object.new, hatchet_app_limit: 0, io: StringIO.new)
|
74
39
|
|
@@ -76,25 +41,30 @@ describe "Reaper" do
|
|
76
41
|
two_days_ago = DateTime.now.new_offset(0) - 2
|
77
42
|
@mock_apps ||= [{"name" => "hatchet-t-foo", "id" => 1, "maintenance" => false, "created_at" => two_days_ago.to_s }]
|
78
43
|
end
|
79
|
-
def reaper.destroy_with_log(name: , id: )
|
44
|
+
def reaper.destroy_with_log(name: , id: , reason: )
|
80
45
|
@reaper_destroy_called_with = {"name" => name, "id" => id}
|
81
46
|
end
|
82
47
|
def reaper.destroy_called_with; @reaper_destroy_called_with; end
|
83
48
|
|
84
|
-
reaper.
|
49
|
+
reaper.clean_old_or_sleep
|
85
50
|
|
86
51
|
expect(reaper.destroy_called_with).to eq({"name" => "hatchet-t-foo", "id" => 1})
|
87
52
|
end
|
88
53
|
|
89
54
|
it "sleeps, refreshes app list, and tries again when an old app is not past TTL" do
|
90
55
|
warning = StringIO.new
|
91
|
-
reaper = Hatchet::Reaper.new(
|
56
|
+
reaper = Hatchet::Reaper.new(
|
57
|
+
api_rate_limit: Object.new,
|
58
|
+
hatchet_app_limit: 0,
|
59
|
+
initial_sleep: 0,
|
60
|
+
io: warning
|
61
|
+
)
|
92
62
|
|
93
63
|
def reaper.get_heroku_apps
|
94
64
|
now = DateTime.now.new_offset(0)
|
95
65
|
@mock_apps ||= [{"name" => "hatchet-t-foo", "id" => 1, "maintenance" => false, "created_at" => now.to_s }]
|
96
66
|
end
|
97
|
-
def reaper.destroy_with_log(name: , id: )
|
67
|
+
def reaper.destroy_with_log(name: , id: , reason: )
|
98
68
|
@reaper_destroy_called_with = {"name" => name, "id" => id}
|
99
69
|
end
|
100
70
|
def reaper.destroy_called_with; @reaper_destroy_called_with; end
|
@@ -104,13 +74,12 @@ describe "Reaper" do
|
|
104
74
|
|
105
75
|
def reaper.get_slept_for_val; @_slept_for; end
|
106
76
|
|
107
|
-
reaper.
|
77
|
+
reaper.clean_old_or_sleep
|
108
78
|
|
109
79
|
expect(reaper.get_slept_for_val).to eq(0)
|
110
80
|
expect(reaper.destroy_called_with).to eq(nil)
|
111
81
|
|
112
|
-
expect(warning.string).to
|
113
|
-
expect(warning.string).to match("total_app_count: 1, hatchet_app_count: 1/#{Hatchet::Reaper::HATCHET_APP_LIMIT}, finished: 0, unfinished: 1")
|
82
|
+
expect(warning.string).to include("WARNING: Hatchet app limit reached (1/0)")
|
114
83
|
end
|
115
84
|
end
|
116
85
|
|
@@ -154,16 +123,4 @@ describe "Reaper" do
|
|
154
123
|
end
|
155
124
|
end
|
156
125
|
end
|
157
|
-
|
158
|
-
it "over limit" do
|
159
|
-
reaper = Hatchet::Reaper.new(api_rate_limit: -> (){}, io: StringIO.new)
|
160
|
-
def reaper.hatchet_app_count; Hatchet::Reaper::HATCHET_APP_LIMIT + 1; end
|
161
|
-
|
162
|
-
expect(reaper.over_limit?).to be_truthy
|
163
|
-
|
164
|
-
reaper = Hatchet::Reaper.new(api_rate_limit: -> (){}, io: StringIO.new)
|
165
|
-
def reaper.hatchet_app_count; Hatchet::Reaper::HATCHET_APP_LIMIT - 1; end
|
166
|
-
|
167
|
-
expect(reaper.over_limit?).to be_falsey
|
168
|
-
end
|
169
126
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: heroku_hatchet
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 8.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Richard Schneeman
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-
|
11
|
+
date: 2023-02-27 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: platform-api
|
@@ -206,6 +206,7 @@ files:
|
|
206
206
|
- spec/hatchet/local_repo_spec.rb
|
207
207
|
- spec/hatchet/lock_spec.rb
|
208
208
|
- spec/spec_helper.rb
|
209
|
+
- spec/unit/default_ci_branch_spec.rb
|
209
210
|
- spec/unit/heroku_run_spec.rb
|
210
211
|
- spec/unit/init_spec.rb
|
211
212
|
- spec/unit/reaper_spec.rb
|
@@ -245,6 +246,7 @@ test_files:
|
|
245
246
|
- spec/hatchet/local_repo_spec.rb
|
246
247
|
- spec/hatchet/lock_spec.rb
|
247
248
|
- spec/spec_helper.rb
|
249
|
+
- spec/unit/default_ci_branch_spec.rb
|
248
250
|
- spec/unit/heroku_run_spec.rb
|
249
251
|
- spec/unit/init_spec.rb
|
250
252
|
- spec/unit/reaper_spec.rb
|