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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e33ffcf73b42d412f81525fdce9b77baa34da4083a6662cb14ddb098a0535dc
4
- data.tar.gz: 3d73f585c00dd74cd4733802af979ba75e2c04e4cb93118128fdaf6efe9bc53b
3
+ metadata.gz: 9e66d5aea4901cc03c37bbf6d0f4b567e49f9c170d471ff461cc642956a195e9
4
+ data.tar.gz: 79fb324886b6a90828a753a9e93679db0386d1696f081efc33b05fef8d2fd5ab
5
5
  SHA512:
6
- metadata.gz: f9ef93379dd21e7808f30d9a2ed6179a76aa12679d5a1129a1ed394682c3e046abcc6c8b87ae8d3112edc5ff77965f90d42550be6d09a49ed89b7ad7270fdf14
7
- data.tar.gz: 6cd06944d64ca9ae96ed8b25744000de748a5235dc52fab838368b9afc75c28af6dc9c1432106a99f972b4044d94762ce573fcaef2fa91f062a68aad2000b05a
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" in this case it could also be tested using `be_true` but
270
- # it's best practice to use this test helper in rspec
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 this behavior [see how to do it in the reference tests](https://github.com/heroku/hatchet/blob/master/spec/hatchet/app_spec.rb)
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
- By default, Hatchet does not destroy your app at the end of the test run, that way if your test failed unexpectedly if it's not destroyed yet, you can:
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 clean up resources, mark the app as being finished (by setting `{"maintenance" => true}` on the app) so that the reaper knows it is safe to delete later. Here is an example of a test that creates and deploys an app manually, then later tears it down manually. If you deploy an application without calling `teardown!` then Hatchet will not know it is safe to delete and may keep it around for much longer than required for the test to finish.
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.teardown! if @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 **hatchet** apps that Hatchet will allow in the given account before running the reaper. For local execution, keep this low as you don't want your account dominated by hatchet apps. For CI, you want it to be much larger, 80-100 since it's not competing with non-hatchet apps. Your test runner account needs to be a dedicated account.
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
- $ Hatchet --help
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 [NAME]", "Deletes applications"
110
- option :all, :type => :boolean
111
- def destroy(name=nil)
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
- if options[:all]
118
+ case
119
+ when options[:all]
120
+ puts "Destroying ALL apps"
118
121
  reaper.destroy_all
119
- elsif !name.nil?
120
- reaper.destroy_by_name(name)
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 "Must provide an app name or --all"
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'] || ENV['HEROKU_TEST_RUN_BRANCH'] || Hatchet.git_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
- @reaper.cycle(app_exception_message: e.message)
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 @app_is_setup
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
- @app_update_info = platform_api.app.update(name, { maintenance: true }) if @app_is_setup
368
- @reaper.cycle if @app_is_setup
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
@@ -1,167 +1,114 @@
1
1
  require 'tmpdir'
2
2
 
3
3
  module Hatchet
4
- # This class lazilly deletes hatchet apps
4
+ # Delete apps
5
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.
6
+ # Delete a single app:
9
7
  #
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).
8
+ # @reaper.destroy_with_log(id: id, name: name, reason: "console")
12
9
  #
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".
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
- @finished_hatchet_apps = []
42
- @unfinished_hatchet_apps = []
43
- @app_count = 0
44
- @hatchet_app_limit = hatchet_app_limit
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
- def cycle(app_exception_message: false)
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
- refresh_app_list if @finished_hatchet_apps.empty?
50
+ destroy_older_apps(force_refresh: true)
54
51
 
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
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: Running reaper due to exception on app
60
- #{stats_string}
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
- while over_limit?
67
- reap_once
60
+ sleep(sleep_for)
61
+ end
68
62
  end
69
63
  ensure
70
64
  mutex_file.close
71
65
  end
72
66
 
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}"
75
- end
76
-
77
- def over_limit?
78
- hatchet_app_count > hatchet_app_limit
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
- (@finished_hatchet_apps + @unfinished_hatchet_apps).each do |app|
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
- private def destroy_with_log(name:, id:)
164
- message = "Destroying #{name.inspect}: #{id}, #{stats_string}"
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
 
@@ -1,3 +1,3 @@
1
1
  module Hatchet
2
- VERSION = "7.4.0"
2
+ VERSION = "8.0.0"
3
3
  end
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
- # TRAVIS_BRANCH works fine unless the build is a pull-request. In that case, it will contain the target branch
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
- out = `git rev-parse --abbrev-ref HEAD`.strip
36
- raise "Attempting to find current branch name. Error: Cannot describe git: #{out}" unless $?.success?
37
- out
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"]
@@ -43,14 +43,14 @@ describe "AppTest" do
43
43
 
44
44
  reaper = app.reaper
45
45
 
46
- def reaper.cycle(app_exception_message: ); @app_exception_message = app_exception_message; end
47
- def reaper.recorded_app_exception_message; @app_exception_message; end
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.recorded_app_exception_message).to match("made you look")
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
@@ -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
@@ -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.cycle
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.cycle
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(api_rate_limit: Object.new, hatchet_app_limit: 1, initial_sleep: 0, io: warning)
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.cycle(app_exception_message: true)
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 match("WARNING")
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: 7.4.0
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-01-23 00:00:00.000000000 Z
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