heroku_hatchet 7.2.0 → 7.3.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 +21 -1
- data/README.md +19 -2
- data/lib/hatchet.rb +1 -0
- data/lib/hatchet/app.rb +97 -68
- data/lib/hatchet/git_app.rb +8 -6
- data/lib/hatchet/heroku_run.rb +96 -0
- data/lib/hatchet/test_run.rb +4 -3
- data/lib/hatchet/version.rb +1 -1
- data/spec/hatchet/app_spec.rb +80 -33
- data/spec/spec_helper.rb +2 -1
- data/spec/unit/heroku_run_spec.rb +115 -0
- data/spec/unit/shell_throttle_spec.rb +94 -0
- metadata +7 -4
- data/spec/unit/shell_throttle.rb +0 -28
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f133f985be889073e9f62caf69e73e05373eecaed11cb13a209fe6bd80874f1c
|
4
|
+
data.tar.gz: 910dd76398351f6ecf19e6a882b7652fe531e6a19843fa64d3f5e57ef5e68696
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 952b664cbb78a6b7250f98a3532a5754bf3fe16526ef915594504d6c9ee77ddf97ac204ab9438d3223da33f431c27e335c9495c86c74baeb69db45fd22682f63
|
7
|
+
data.tar.gz: e282d36a9196b74f9999c7c7b57ae55cd1da508f09187058baf92fa711800e882c806299f472ac06cceda88cfe2eba63b3e7eb67c0ac0e657436fc1c207f0e02
|
data/CHANGELOG.md
CHANGED
@@ -1,6 +1,26 @@
|
|
1
1
|
## HEAD
|
2
2
|
|
3
|
-
## 7.
|
3
|
+
## 7.3.0
|
4
|
+
|
5
|
+
- Deprecations
|
6
|
+
- Deprecation: Calling `App#before_deploy` as a way to clear/replace the existing block should now be done with `App#before_deploy(:replace)` (https://github.com/heroku/hatchet/pull/126)
|
7
|
+
- Deprecation: HATCHET_BUILDPACK_BASE default (https://github.com/heroku/hatchet/pull/133)
|
8
|
+
- Deprecation: App#directory (https://github.com/heroku/hatchet/pull/135)
|
9
|
+
|
10
|
+
- Flappy test improvements
|
11
|
+
- Increase CI timeout limit to 900 seconds (15 minutes) (https://github.com/heroku/hatchet/pull/137)
|
12
|
+
- Empty string returns from App#run now trigger retries (https://github.com/heroku/hatchet/pull/132)
|
13
|
+
- Rescue 403 on pipeline delete (https://github.com/heroku/hatchet/pull/130)
|
14
|
+
- Additional rate throttle cases handled (https://github.com/heroku/hatchet/pull/128)
|
15
|
+
|
16
|
+
- Usability
|
17
|
+
- Annotate rspec expectation failures inside of deploy blocks with hatchet debug information (https://github.com/heroku/hatchet/pull/136)
|
18
|
+
- Hatchet#new raises a helpful error when no source code location is provided (https://github.com/heroku/hatchet/pull/134)
|
19
|
+
- Lazy evaluation of HATCHET_BUILDPACK_BASE env var (https://github.com/heroku/hatchet/pull/133)
|
20
|
+
- Allow multiple `App#before_deploy` blocks to be set and called (https://github.com/heroku/hatchet/pull/126)
|
21
|
+
- Performance improvement when running without an explicit HEROKU_API_KEY set (https://github.com/heroku/hatchet/pull/128)
|
22
|
+
|
23
|
+
## 7.2.0
|
4
24
|
|
5
25
|
- App#setup! no longer modifies files on disk. (https://github.com/heroku/hatchet/pull/125)
|
6
26
|
- Add `$ hatchet init` command for bootstrapping new projects (https://github.com/heroku/hatchet/pull/123)
|
data/README.md
CHANGED
@@ -525,7 +525,7 @@ end
|
|
525
525
|
|
526
526
|
In this example, the app would use the nodejs buildpack, and then `:default` gets replaced by your Git url and branch name.
|
527
527
|
|
528
|
-
- before_deploy (Block): Instead of using the `tap` syntax you can provide a block directly to hatchet app initialization:
|
528
|
+
- before_deploy (Block): Instead of using the `tap` syntax you can provide a block directly to hatchet app initialization. Example:
|
529
529
|
|
530
530
|
```ruby
|
531
531
|
Hatchet::Runner.new("default_ruby", before_deploy: ->{ FileUtils.touch("foo.txt")}).deploy do
|
@@ -630,6 +630,23 @@ Hatchet::Runner.new("default_ruby", before_deploy: before_deploy_proc).deploy do
|
|
630
630
|
end
|
631
631
|
```
|
632
632
|
|
633
|
+
You can call multiple blocks by specifying (`:prepend` or `:append`):
|
634
|
+
|
635
|
+
```ruby
|
636
|
+
Hatchet::Runner.new("default_ruby").tap do |app|
|
637
|
+
app.before_deploy do
|
638
|
+
FileUtils.touch("foo.txt")
|
639
|
+
end
|
640
|
+
|
641
|
+
app.before_deploy(:append) do
|
642
|
+
FileUtils.touch("bar.txt")
|
643
|
+
end
|
644
|
+
app.deploy do
|
645
|
+
end
|
646
|
+
end
|
647
|
+
```
|
648
|
+
|
649
|
+
|
633
650
|
- `app.commit!`: Will updates the contents of your local git dir if you've modified files on disk
|
634
651
|
|
635
652
|
```ruby
|
@@ -660,7 +677,7 @@ end
|
|
660
677
|
> Note: If you want to execute tests in this temp directory, you likely want to use `in_directory_fork` otherwise, you might accidentally contaminate the current environment's variables if you modify them.
|
661
678
|
|
662
679
|
- `app.in_directory_fork`: Runs the given block in a temp directory and inside of a forked process, an example given above.
|
663
|
-
- `app.
|
680
|
+
- `app.original_source_code_directory`: Returns the directory of the example application on disk, this is NOT the temp directory that you're currently executing against. It's probably not what you want.
|
664
681
|
- `app.deploy`: Your main method takes a block to execute after the deploy is successful. If no block is provided, you must manually call `app.teardown!` (see below for an example).
|
665
682
|
- `app.output`: The output contents of the deploy
|
666
683
|
- `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.
|
data/lib/hatchet.rb
CHANGED
data/lib/hatchet/app.rb
CHANGED
@@ -5,11 +5,15 @@ require 'tmpdir'
|
|
5
5
|
|
6
6
|
module Hatchet
|
7
7
|
class App
|
8
|
-
HATCHET_BUILDPACK_BASE
|
8
|
+
HATCHET_BUILDPACK_BASE = -> {
|
9
|
+
ENV.fetch('HATCHET_BUILDPACK_BASE') {
|
10
|
+
warn "ENV HATCHET_BUILDPACK_BASE is not set. It currently defaults to the ruby buildpack. In the future this env var will be required"
|
11
|
+
"https://github.com/heroku/heroku-buildpack-ruby.git"
|
12
|
+
}
|
13
|
+
}
|
9
14
|
HATCHET_BUILDPACK_BRANCH = -> { ENV['HATCHET_BUILDPACK_BRANCH'] || ENV['HEROKU_TEST_RUN_BRANCH'] || Hatchet.git_branch }
|
10
|
-
BUILDPACK_URL = "https://github.com/heroku/heroku-buildpack-ruby.git"
|
11
15
|
|
12
|
-
attr_reader :name, :stack, :
|
16
|
+
attr_reader :name, :stack, :repo_name, :app_config, :buildpacks, :reaper, :max_retries_count
|
13
17
|
|
14
18
|
class FailedDeploy < StandardError; end
|
15
19
|
|
@@ -18,7 +22,7 @@ module Hatchet
|
|
18
22
|
|
19
23
|
def initialize(app, message, output: )
|
20
24
|
@output = output
|
21
|
-
msg = "Could not deploy '#{app.name}' (#{app.repo_name}) using '#{app.class}' at path: '#{app.
|
25
|
+
msg = "Could not deploy '#{app.name}' (#{app.repo_name}) using '#{app.class}' at path: '#{app.original_source_code_directory}'\n"
|
22
26
|
msg << "if this was expected add `allow_failure: true` to your deploy hash.\n"
|
23
27
|
msg << "#{message}\n"
|
24
28
|
msg << "output:\n"
|
@@ -32,7 +36,7 @@ module Hatchet
|
|
32
36
|
|
33
37
|
def initialize(app, message, output: )
|
34
38
|
@output = output
|
35
|
-
msg = "Could not release '#{app.name}' (#{app.repo_name}) using '#{app.class}' at path: '#{app.
|
39
|
+
msg = "Could not release '#{app.name}' (#{app.repo_name}) using '#{app.class}' at path: '#{app.original_source_code_directory}'\n"
|
36
40
|
msg << "if this was expected add `allow_failure: true` to your deploy hash.\n"
|
37
41
|
msg << "#{message}\n"
|
38
42
|
msg << "output:\n"
|
@@ -42,8 +46,9 @@ module Hatchet
|
|
42
46
|
end
|
43
47
|
|
44
48
|
SkipDefaultOption = Object.new
|
49
|
+
DEFAULT_REPO_NAME = Object.new
|
45
50
|
|
46
|
-
def initialize(repo_name,
|
51
|
+
def initialize(repo_name = DEFAULT_REPO_NAME,
|
47
52
|
stack: "",
|
48
53
|
name: default_name,
|
49
54
|
debug: nil,
|
@@ -58,6 +63,7 @@ module Hatchet
|
|
58
63
|
retries: RETRIES,
|
59
64
|
config: {}
|
60
65
|
)
|
66
|
+
raise "You tried creating a Hatchet::App instance without source code, pass in a path to an app to deploy or the name of an app in your hatchet.json" if repo_name == DEFAULT_REPO_NAME
|
61
67
|
@repo_name = repo_name
|
62
68
|
@directory = self.config.path_for_name(@repo_name)
|
63
69
|
@name = name
|
@@ -78,13 +84,36 @@ module Hatchet
|
|
78
84
|
@already_in_dir = nil
|
79
85
|
@app_is_setup = nil
|
80
86
|
|
81
|
-
@
|
87
|
+
@before_deploy_array = []
|
88
|
+
@before_deploy_array << before_deploy if before_deploy
|
82
89
|
@app_config = config
|
83
90
|
@reaper = Reaper.new(api_rate_limit: api_rate_limit)
|
84
91
|
end
|
85
92
|
|
93
|
+
private def test_failure_classes
|
94
|
+
class_array = []
|
95
|
+
class_array << RSpec::Expectations::ExpectationNotMetError if defined?(RSpec::Expectations::ExpectationNotMetError)
|
96
|
+
class_array
|
97
|
+
end
|
98
|
+
|
99
|
+
def annotate_failures
|
100
|
+
yield
|
101
|
+
rescue *test_failure_classes => e
|
102
|
+
raise e, "App: #{name} (#{@repo_name})\n#{e.message}"
|
103
|
+
end
|
104
|
+
|
105
|
+
def directory
|
106
|
+
warn "Calling App#directory returns the original location of the app's source code that should not be modified, if this is really what you want use `original_source_code_directory` instead."
|
107
|
+
warn caller
|
108
|
+
@directory
|
109
|
+
end
|
110
|
+
|
111
|
+
def original_source_code_directory
|
112
|
+
@directory
|
113
|
+
end
|
114
|
+
|
86
115
|
def self.default_buildpack
|
87
|
-
[HATCHET_BUILDPACK_BASE, HATCHET_BUILDPACK_BRANCH.call].join("#")
|
116
|
+
[HATCHET_BUILDPACK_BASE.call, HATCHET_BUILDPACK_BRANCH.call].join("#")
|
88
117
|
end
|
89
118
|
|
90
119
|
def allow_failure?
|
@@ -157,32 +186,17 @@ module Hatchet
|
|
157
186
|
command = command.to_s
|
158
187
|
end
|
159
188
|
|
160
|
-
heroku_command = build_heroku_command(command, options)
|
161
|
-
|
162
189
|
allow_run_multi! if @run_multi
|
163
190
|
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
return output
|
172
|
-
end
|
173
|
-
|
174
|
-
private def build_heroku_command(command, options = {})
|
175
|
-
command = command.shellescape unless options.delete(:raw)
|
176
|
-
|
177
|
-
default_options = { "app" => name, "exit-code" => nil }
|
178
|
-
heroku_options = (default_options.merge(options.delete(:heroku) || {})).map do |k,v|
|
179
|
-
next if v == Hatchet::App::SkipDefaultOption # for forcefully removing e.g. --exit-code, a user can pass this
|
180
|
-
arg = "--#{k.to_s.shellescape}"
|
181
|
-
arg << "=#{v.to_s.shellescape}" unless v.nil? # nil means we include the option without an argument
|
182
|
-
arg
|
183
|
-
end.join(" ")
|
191
|
+
run_obj = Hatchet::HerokuRun.new(
|
192
|
+
command,
|
193
|
+
app: self,
|
194
|
+
retry_on_empty: options.fetch(:retry_on_empty, !ENV["HATCHET_DISABLE_EMPTY_RUN_RETRY"]),
|
195
|
+
heroku: options[:heroku],
|
196
|
+
raw: options[:raw]
|
197
|
+
).call
|
184
198
|
|
185
|
-
|
199
|
+
return run_obj.output
|
186
200
|
end
|
187
201
|
|
188
202
|
private def allow_run_multi!
|
@@ -191,7 +205,6 @@ module Hatchet
|
|
191
205
|
@run_multi_is_setup ||= platform_api.formation.update(name, "web", {"size" => "Standard-1X"})
|
192
206
|
end
|
193
207
|
|
194
|
-
|
195
208
|
# Allows multiple commands to be run concurrently in the background.
|
196
209
|
#
|
197
210
|
# WARNING! Using the feature requres that the underlying app is not on the "free" Heroku
|
@@ -229,23 +242,15 @@ module Hatchet
|
|
229
242
|
allow_run_multi!
|
230
243
|
|
231
244
|
run_thread = Thread.new do
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
yield out, status
|
243
|
-
|
244
|
-
# if block.arity == 1
|
245
|
-
# block.call(out)
|
246
|
-
# else
|
247
|
-
# block.call(out, status)
|
248
|
-
# end
|
245
|
+
run_obj = Hatchet::HerokuRun.new(
|
246
|
+
command,
|
247
|
+
app: self,
|
248
|
+
retry_on_empty: options.fetch(:retry_on_empty, !ENV["HATCHET_DISABLE_EMPTY_RUN_RETRY"]),
|
249
|
+
heroku: options[:heroku],
|
250
|
+
raw: options[:raw]
|
251
|
+
).call
|
252
|
+
|
253
|
+
yield run_obj.output, run_obj.status
|
249
254
|
end
|
250
255
|
run_thread.abort_on_exception = true
|
251
256
|
|
@@ -318,9 +323,25 @@ module Hatchet
|
|
318
323
|
end
|
319
324
|
end
|
320
325
|
|
321
|
-
def before_deploy(&block)
|
326
|
+
def before_deploy(behavior = :default, &block)
|
322
327
|
raise "block required" unless block
|
323
|
-
|
328
|
+
|
329
|
+
case behavior
|
330
|
+
when :default, :replace
|
331
|
+
if @before_deploy_array.any? && behavior == :default
|
332
|
+
STDERR.puts "Calling App#before_deploy multiple times will overwrite the contents. If you intended this: use `App#before_deploy(:replace)`"
|
333
|
+
STDERR.puts "In the future, calling this method with no arguements will default to `App#before_deploy(:append)` behavior.\n#{caller.join("\n")}"
|
334
|
+
end
|
335
|
+
|
336
|
+
@before_deploy_array.clear
|
337
|
+
@before_deploy_array << block
|
338
|
+
when :prepend
|
339
|
+
@before_deploy_array = [block] + @before_deploy_array
|
340
|
+
when :append
|
341
|
+
@before_deploy_array << block
|
342
|
+
else
|
343
|
+
raise "Unrecognized behavior: #{behavior.inspect}, valid inputs are :append, :prepend, and :replace"
|
344
|
+
end
|
324
345
|
|
325
346
|
self
|
326
347
|
end
|
@@ -346,14 +367,14 @@ module Hatchet
|
|
346
367
|
@reaper.cycle if @app_is_setup
|
347
368
|
end
|
348
369
|
|
349
|
-
def in_directory
|
350
|
-
yield
|
370
|
+
def in_directory
|
371
|
+
yield and return if @already_in_dir
|
351
372
|
|
352
373
|
Dir.mktmpdir do |tmpdir|
|
353
|
-
FileUtils.cp_r("#{
|
374
|
+
FileUtils.cp_r("#{original_source_code_directory}/.", "#{tmpdir}/.")
|
354
375
|
Dir.chdir(tmpdir) do
|
355
376
|
@already_in_dir = true
|
356
|
-
yield
|
377
|
+
yield
|
357
378
|
@already_in_dir = false
|
358
379
|
end
|
359
380
|
end
|
@@ -387,9 +408,11 @@ module Hatchet
|
|
387
408
|
|
388
409
|
def deploy(&block)
|
389
410
|
in_directory do
|
390
|
-
|
391
|
-
|
392
|
-
|
411
|
+
annotate_failures do
|
412
|
+
in_dir_setup!
|
413
|
+
self.push_with_retry!
|
414
|
+
block.call(self, api_rate_limit.call, output) if block_given?
|
415
|
+
end
|
393
416
|
end
|
394
417
|
ensure
|
395
418
|
self.teardown! if block_given?
|
@@ -423,21 +446,21 @@ module Hatchet
|
|
423
446
|
end
|
424
447
|
|
425
448
|
def api_key
|
426
|
-
@api_key ||= ENV['HEROKU_API_KEY']
|
449
|
+
@api_key ||= ENV['HEROKU_API_KEY'] ||= `heroku auth:token`.chomp
|
427
450
|
end
|
428
451
|
|
429
452
|
def heroku
|
430
453
|
raise "Not supported, use `platform_api` instead."
|
431
454
|
end
|
432
455
|
|
433
|
-
def run_ci(timeout:
|
456
|
+
def run_ci(timeout: 900, &block)
|
434
457
|
in_directory do
|
435
458
|
max_retries_count.times.retry do
|
436
459
|
result = create_pipeline
|
437
460
|
@pipeline_id = result["id"]
|
438
461
|
end
|
439
462
|
|
440
|
-
#
|
463
|
+
# When the CI run finishes, the associated ephemeral app created for the test run internally gets removed almost immediately
|
441
464
|
# the system then sees a pipeline with no apps, and deletes it, also almost immediately
|
442
465
|
# that would, with bad timing, mean our test run info poll in wait! would 403, and/or the delete_pipeline at the end
|
443
466
|
# that's why we create an app explictly (or maybe it already exists), and then associate it with with the pipeline
|
@@ -495,6 +518,9 @@ module Hatchet
|
|
495
518
|
|
496
519
|
def delete_pipeline(pipeline_id)
|
497
520
|
api_rate_limit.call.pipeline.delete(pipeline_id)
|
521
|
+
rescue Excon::Error::Forbidden
|
522
|
+
warn "Error deleting pipeline id: #{pipeline_id.inspect}, status: 403"
|
523
|
+
# Means the pipeline likely doesn't exist, not sure why though
|
498
524
|
end
|
499
525
|
|
500
526
|
def platform_api
|
@@ -534,14 +560,17 @@ module Hatchet
|
|
534
560
|
end
|
535
561
|
|
536
562
|
private def call_before_deploy
|
537
|
-
return unless @
|
538
|
-
raise "before_deploy: #{@before_deploy.inspect} must respond to :call" unless @before_deploy.respond_to?(:call)
|
539
|
-
raise "before_deploy: #{@before_deploy.inspect} must respond to :arity" unless @before_deploy.respond_to?(:arity)
|
563
|
+
return unless @before_deploy_array.any?
|
540
564
|
|
541
|
-
|
542
|
-
|
543
|
-
|
544
|
-
|
565
|
+
@before_deploy_array.each do |block|
|
566
|
+
raise "before_deploy: #{block.inspect} must respond to :call" unless block.respond_to?(:call)
|
567
|
+
raise "before_deploy: #{block.inspect} must respond to :arity" unless block.respond_to?(:arity)
|
568
|
+
|
569
|
+
if block.arity == 1
|
570
|
+
block.call(self)
|
571
|
+
else
|
572
|
+
block.call
|
573
|
+
end
|
545
574
|
end
|
546
575
|
|
547
576
|
commit! if needs_commit?
|
data/lib/hatchet/git_app.rb
CHANGED
@@ -5,25 +5,28 @@ module Hatchet
|
|
5
5
|
"https://git.heroku.com/#{name}.git"
|
6
6
|
end
|
7
7
|
|
8
|
-
|
9
8
|
def push_without_retry!
|
10
9
|
output = ""
|
11
10
|
|
12
11
|
ShellThrottle.new(platform_api: @platform_api).call do
|
13
12
|
output = git_push_heroku_yall
|
14
13
|
rescue FailedDeploy => e
|
15
|
-
|
14
|
+
case e.output
|
15
|
+
when /reached the API rate limit/, /429 Too Many Requests/
|
16
16
|
throw(:throttle)
|
17
|
-
elsif @allow_failure
|
18
|
-
output = e.output
|
19
17
|
else
|
20
|
-
raise e
|
18
|
+
raise e unless @allow_failure
|
19
|
+
output = e.output
|
21
20
|
end
|
22
21
|
end
|
23
22
|
|
24
23
|
return output
|
25
24
|
end
|
26
25
|
|
26
|
+
def releases
|
27
|
+
platform_api.release.list(name)
|
28
|
+
end
|
29
|
+
|
27
30
|
private def git_push_heroku_yall
|
28
31
|
output = `git push #{git_repo} HEAD:main 2>&1`
|
29
32
|
|
@@ -31,7 +34,6 @@ module Hatchet
|
|
31
34
|
raise FailedDeployError.new(self, "Buildpack: #{@buildpack.inspect}\nRepo: #{git_repo}", output: output)
|
32
35
|
end
|
33
36
|
|
34
|
-
releases = platform_api.release.list(name)
|
35
37
|
if releases.last["status"] == "failed"
|
36
38
|
commit! # An empty commit allows us to deploy again
|
37
39
|
raise FailedReleaseError.new(self, "Buildpack: #{@buildpack.inspect}\nRepo: #{git_repo}", output: output)
|
@@ -0,0 +1,96 @@
|
|
1
|
+
module Hatchet
|
2
|
+
# Used for running Heroku commands
|
3
|
+
#
|
4
|
+
# Example:
|
5
|
+
#
|
6
|
+
# run_obj = HerokuRun.new("ruby -v", app: app).call
|
7
|
+
# puts run_obj.output #=> "ruby 2.7.1p83 (2020-03-31 revision a0c7c23c9c) [x86_64-linux]"
|
8
|
+
# puts run_obj.status.success? #=> true
|
9
|
+
#
|
10
|
+
# There's a bug in specs sometimes where App#run will return an empty
|
11
|
+
# value. When that's detected then the command will be re-run. This can be
|
12
|
+
# optionally disabled by setting `retry_on_empty: false` if you're expecting
|
13
|
+
# the command to be empty.
|
14
|
+
#
|
15
|
+
class HerokuRun
|
16
|
+
attr_reader :command
|
17
|
+
|
18
|
+
def initialize(
|
19
|
+
command,
|
20
|
+
app: ,
|
21
|
+
heroku: {},
|
22
|
+
retry_on_empty: !ENV["HATCHET_DISABLE_EMPTY_RUN_RETRY"],
|
23
|
+
raw: false,
|
24
|
+
stderr: $stderr)
|
25
|
+
|
26
|
+
@raw = raw
|
27
|
+
@app = app
|
28
|
+
@command = build_heroku_command(command, heroku || {})
|
29
|
+
@retry_on_empty = retry_on_empty
|
30
|
+
@stderr = stderr
|
31
|
+
@output = ""
|
32
|
+
@status = nil
|
33
|
+
@empty_fail_count = 0
|
34
|
+
end
|
35
|
+
|
36
|
+
def output
|
37
|
+
raise "You must run `call` on this object first" unless @status
|
38
|
+
@output
|
39
|
+
end
|
40
|
+
|
41
|
+
def status
|
42
|
+
raise "You must run `call` on this object first" unless @status
|
43
|
+
@status
|
44
|
+
end
|
45
|
+
|
46
|
+
def call
|
47
|
+
loop do
|
48
|
+
execute!
|
49
|
+
|
50
|
+
break unless output.empty?
|
51
|
+
break unless @retry_on_empty
|
52
|
+
|
53
|
+
@empty_fail_count += 1
|
54
|
+
|
55
|
+
break if @empty_fail_count >= 3
|
56
|
+
|
57
|
+
message = String.new("Empty output from command #{@command}, retrying the command.")
|
58
|
+
message << "\nTo disable pass in `retry_on_empty: false` or set HATCHET_DISABLE_EMPTY_RUN_RETRY=1 globally"
|
59
|
+
message << "\nfailed_count: #{@empty_fail_count}"
|
60
|
+
message << "\nreleases: #{@app.releases}"
|
61
|
+
message << "\n#{caller.join("\n")}"
|
62
|
+
@stderr.puts message
|
63
|
+
end
|
64
|
+
|
65
|
+
self
|
66
|
+
end
|
67
|
+
|
68
|
+
private def execute!
|
69
|
+
ShellThrottle.new(platform_api: @app.platform_api).call do |throttle|
|
70
|
+
run_shell!
|
71
|
+
throw(:throttle) if output.match?(/reached the API rate limit/)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
private def run_shell!
|
76
|
+
@output = `#{@command}`
|
77
|
+
@status = $?
|
78
|
+
end
|
79
|
+
|
80
|
+
private def build_heroku_command(command, options = {})
|
81
|
+
command = command.shellescape unless @raw
|
82
|
+
|
83
|
+
default_options = { "app" => @app.name, "exit-code" => nil }
|
84
|
+
heroku_options_array = (default_options.merge(options)).map do |k,v|
|
85
|
+
# This was a bad interface decision
|
86
|
+
next if v == Hatchet::App::SkipDefaultOption # for forcefully removing e.g. --exit-code, a user can pass this
|
87
|
+
|
88
|
+
arg = "--#{k.to_s.shellescape}"
|
89
|
+
arg << "=#{v.to_s.shellescape}" unless v.nil? # nil means we include the option without an argument
|
90
|
+
arg
|
91
|
+
end
|
92
|
+
|
93
|
+
"heroku run #{heroku_options_array.compact.join(' ')} -- #{command}"
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
data/lib/hatchet/test_run.rb
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
module Hatchet
|
2
2
|
class FailedTestError < StandardError
|
3
3
|
def initialize(app, output)
|
4
|
-
msg = "Could not run tests on pipeline id: '#{app.pipeline_id}' (#{app.repo_name}) at path: '#{app.
|
4
|
+
msg = "Could not run tests on pipeline id: '#{app.pipeline_id}' (#{app.repo_name}) at path: '#{app.original_source_code_directory}'\n" <<
|
5
5
|
" if this was expected add `allow_failure: true` to your hatchet initialization hash.\n" <<
|
6
6
|
"output:\n" <<
|
7
7
|
"#{output}"
|
@@ -140,8 +140,9 @@ module Hatchet
|
|
140
140
|
end
|
141
141
|
end
|
142
142
|
rescue Timeout::Error
|
143
|
-
|
144
|
-
|
143
|
+
message = "Timed out status: #{@status}, timeout: #{@timeout}, app: #{app.name}"
|
144
|
+
puts message
|
145
|
+
raise FailedTestError.new(self.app, "#{message}, output:\n#{self.output}") unless app.allow_failure?
|
145
146
|
yield self
|
146
147
|
return self
|
147
148
|
end
|
data/lib/hatchet/version.rb
CHANGED
data/spec/hatchet/app_spec.rb
CHANGED
@@ -1,6 +1,20 @@
|
|
1
1
|
require("spec_helper")
|
2
2
|
|
3
3
|
describe "AppTest" do
|
4
|
+
it "annotates rspec expectation failures" do
|
5
|
+
app = Hatchet::Runner.new("default_ruby")
|
6
|
+
error = nil
|
7
|
+
begin
|
8
|
+
app.annotate_failures do
|
9
|
+
expect(true).to eq(false)
|
10
|
+
end
|
11
|
+
rescue RSpec::Expectations::ExpectationNotMetError => e
|
12
|
+
error = e
|
13
|
+
end
|
14
|
+
|
15
|
+
expect(error.message).to include(app.name)
|
16
|
+
end
|
17
|
+
|
4
18
|
it "does not modify local files by mistake" do
|
5
19
|
Dir.mktmpdir do |dir_1|
|
6
20
|
app = Hatchet::Runner.new(dir_1)
|
@@ -23,26 +37,6 @@ describe "AppTest" do
|
|
23
37
|
end
|
24
38
|
end
|
25
39
|
|
26
|
-
it "rate throttles `git push` " do
|
27
|
-
app = Hatchet::GitApp.new("default_ruby")
|
28
|
-
def app.git_push_heroku_yall
|
29
|
-
@_git_push_heroku_yall_call_count ||= 0
|
30
|
-
@_git_push_heroku_yall_call_count += 1
|
31
|
-
if @_git_push_heroku_yall_call_count >= 2
|
32
|
-
"Success"
|
33
|
-
else
|
34
|
-
raise Hatchet::App::FailedDeployError.new(self, "message", output: "Your account reached the API rate limit Please wait a few minutes before making new requests")
|
35
|
-
end
|
36
|
-
end
|
37
|
-
|
38
|
-
def app.sleep_called?; @sleep_called; end
|
39
|
-
|
40
|
-
def app.what_is_git_push_heroku_yall_call_count; @_git_push_heroku_yall_call_count; end
|
41
|
-
app.push_without_retry!
|
42
|
-
|
43
|
-
expect(app.what_is_git_push_heroku_yall_call_count).to be(2)
|
44
|
-
end
|
45
|
-
|
46
40
|
it "calls reaper if cannot create an app" do
|
47
41
|
app = Hatchet::App.new("default_ruby", buildpacks: [:default])
|
48
42
|
def app.heroku_api_create_app(*args); raise StandardError.new("made you look"); end
|
@@ -102,22 +96,75 @@ describe "AppTest" do
|
|
102
96
|
expect(app_update_info["maintenance"]).to be_truthy
|
103
97
|
end
|
104
98
|
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
99
|
+
describe "before deploy" do
|
100
|
+
it "dir" do
|
101
|
+
@called = false
|
102
|
+
@dir = false
|
103
|
+
app = Hatchet::App.new("default_ruby")
|
104
|
+
def app.push_with_retry!
|
105
|
+
# do nothing
|
106
|
+
end
|
107
|
+
app.before_deploy do
|
108
|
+
@called = true
|
109
|
+
@dir = Dir.pwd
|
110
|
+
end
|
111
|
+
app.deploy do
|
112
|
+
expect(@called).to eq(true)
|
113
|
+
expect(@dir).to eq(Dir.pwd)
|
114
|
+
end
|
115
|
+
expect(@dir).to_not eq(Dir.pwd)
|
111
116
|
end
|
112
|
-
|
113
|
-
|
114
|
-
@
|
117
|
+
|
118
|
+
it "prepend" do
|
119
|
+
@value = ""
|
120
|
+
app = Hatchet::App.new("default_ruby")
|
121
|
+
def app.push_with_retry!; end
|
122
|
+
app.before_deploy do
|
123
|
+
@value << "there"
|
124
|
+
end
|
125
|
+
|
126
|
+
app.before_deploy(:prepend) do
|
127
|
+
@value << "hello "
|
128
|
+
end
|
129
|
+
app.deploy do
|
130
|
+
end
|
131
|
+
|
132
|
+
expect(@value).to eq("hello there")
|
115
133
|
end
|
116
|
-
|
117
|
-
|
118
|
-
|
134
|
+
|
135
|
+
it "append" do
|
136
|
+
@value = ""
|
137
|
+
app = Hatchet::App.new("default_ruby")
|
138
|
+
def app.push_with_retry!; end
|
139
|
+
app.before_deploy do
|
140
|
+
@value << "there"
|
141
|
+
end
|
142
|
+
|
143
|
+
app.before_deploy(:append) do
|
144
|
+
@value << " hello"
|
145
|
+
end
|
146
|
+
app.deploy do
|
147
|
+
end
|
148
|
+
|
149
|
+
expect(@value).to eq("there hello")
|
150
|
+
end
|
151
|
+
|
152
|
+
it "replace" do
|
153
|
+
@value = ""
|
154
|
+
app = Hatchet::App.new("default_ruby")
|
155
|
+
def app.push_with_retry!; end
|
156
|
+
app.before_deploy do
|
157
|
+
@value << "there"
|
158
|
+
end
|
159
|
+
|
160
|
+
app.before_deploy(:replace) do
|
161
|
+
@value << "hello"
|
162
|
+
end
|
163
|
+
app.deploy do
|
164
|
+
end
|
165
|
+
|
166
|
+
expect(@value).to eq("hello")
|
119
167
|
end
|
120
|
-
expect(@dir).to_not eq(Dir.pwd)
|
121
168
|
end
|
122
169
|
|
123
170
|
it "auto commits code" do
|
data/spec/spec_helper.rb
CHANGED
@@ -14,7 +14,8 @@ RSpec.configure do |config|
|
|
14
14
|
end
|
15
15
|
end
|
16
16
|
|
17
|
-
ENV['
|
17
|
+
ENV['HATCHET_BUILDPACK_BASE'] = "https://github.com/heroku/heroku-buildpack-ruby.git"
|
18
|
+
ENV['HATCHET_BUILDPACK_BRANCH'] = "main"
|
18
19
|
|
19
20
|
require 'parallel_tests/test/runtime_logger' if ENV['RECORD_RUNTIME']
|
20
21
|
|
@@ -0,0 +1,115 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe "HerokuRun" do
|
4
|
+
def fake_app
|
5
|
+
app = Object.new
|
6
|
+
def app.name; "fake_app"; end
|
7
|
+
app
|
8
|
+
end
|
9
|
+
|
10
|
+
describe "options" do
|
11
|
+
it "escapes by default" do
|
12
|
+
run_obj = Hatchet::HerokuRun.new("ruby -v", app: fake_app)
|
13
|
+
expect(run_obj.command).to eq("heroku run --app=fake_app --exit-code -- ruby\\ -v")
|
14
|
+
end
|
15
|
+
|
16
|
+
it "escapes by default" do
|
17
|
+
run_obj = Hatchet::HerokuRun.new("ruby -v", app: fake_app, heroku: { "exit-code" => Hatchet::App::SkipDefaultOption })
|
18
|
+
expect(run_obj.command).to eq("heroku run --app=fake_app -- ruby\\ -v")
|
19
|
+
end
|
20
|
+
|
21
|
+
it "allows setting switch values by default" do
|
22
|
+
run_obj = Hatchet::HerokuRun.new("ruby -v", app: fake_app, heroku: { "no-tty" => nil })
|
23
|
+
expect(run_obj.command).to eq("heroku run --app=fake_app --exit-code --no-tty -- ruby\\ -v")
|
24
|
+
end
|
25
|
+
|
26
|
+
it "can be used to pass env vars" do
|
27
|
+
run_obj = Hatchet::HerokuRun.new("ruby -v", app: fake_app, heroku: { "env" => "HELLO=ohai;NAME=world" })
|
28
|
+
expect(run_obj.command).to eq("heroku run --app=fake_app --exit-code --env=HELLO\\=ohai\\;NAME\\=world -- ruby\\ -v")
|
29
|
+
end
|
30
|
+
|
31
|
+
|
32
|
+
it "lets me use raw values" do
|
33
|
+
run_obj = Hatchet::HerokuRun.new("ruby -v", app: fake_app, raw: true )
|
34
|
+
expect(run_obj.command).to eq("heroku run --app=fake_app --exit-code -- ruby -v")
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
describe "retry on empty" do
|
39
|
+
before(:all) do
|
40
|
+
@app = Hatchet::Runner.new("default_ruby")
|
41
|
+
@app.setup!
|
42
|
+
end
|
43
|
+
|
44
|
+
after(:all) do
|
45
|
+
@app.teardown!
|
46
|
+
end
|
47
|
+
|
48
|
+
it "retries 3 times on empty result" do
|
49
|
+
stderr = StringIO.new
|
50
|
+
run_obj = Hatchet::HerokuRun.new("ruby -v", app: @app, stderr: stderr)
|
51
|
+
|
52
|
+
def run_obj.run_shell!
|
53
|
+
@output = ""
|
54
|
+
@status = Object.new
|
55
|
+
end
|
56
|
+
|
57
|
+
run_obj.call
|
58
|
+
|
59
|
+
expect(run_obj.instance_variable_get(:@empty_fail_count)).to eq(3)
|
60
|
+
expect(stderr.string).to include("retrying the command.")
|
61
|
+
end
|
62
|
+
|
63
|
+
it "retries 0 times on NON empty result" do
|
64
|
+
stderr = StringIO.new
|
65
|
+
run_obj = Hatchet::HerokuRun.new("ruby -v", app: @app, stderr: stderr)
|
66
|
+
|
67
|
+
def run_obj.run_shell!
|
68
|
+
@output = "not empty"
|
69
|
+
@status = Object.new
|
70
|
+
end
|
71
|
+
|
72
|
+
run_obj.call
|
73
|
+
|
74
|
+
expect(run_obj.instance_variable_get(:@empty_fail_count)).to eq(0)
|
75
|
+
expect(run_obj.output).to eq("not empty")
|
76
|
+
end
|
77
|
+
|
78
|
+
it "retries 0 times on empty result when disabled" do
|
79
|
+
stderr = StringIO.new
|
80
|
+
run_obj = Hatchet::HerokuRun.new("ruby -v", app: @app, stderr: stderr, retry_on_empty: false)
|
81
|
+
|
82
|
+
def run_obj.run_shell!
|
83
|
+
@output = ""
|
84
|
+
@status = Object.new
|
85
|
+
end
|
86
|
+
|
87
|
+
run_obj.call
|
88
|
+
|
89
|
+
expect(run_obj.instance_variable_get(:@empty_fail_count)).to eq(0)
|
90
|
+
expect(stderr.string).to_not include("retrying the command.")
|
91
|
+
end
|
92
|
+
|
93
|
+
it "retries 0 times on empty result when disabled via ENV var" do
|
94
|
+
begin
|
95
|
+
original_env = ENV["HATCHET_DISABLE_EMPTY_RUN_RETRY"]
|
96
|
+
ENV["HATCHET_DISABLE_EMPTY_RUN_RETRY"] = "1"
|
97
|
+
stderr = StringIO.new
|
98
|
+
run_obj = Hatchet::HerokuRun.new("ruby -v", app: @app, stderr: stderr)
|
99
|
+
|
100
|
+
def run_obj.run_shell!
|
101
|
+
@output = ""
|
102
|
+
@status = Object.new
|
103
|
+
end
|
104
|
+
|
105
|
+
run_obj.call
|
106
|
+
|
107
|
+
expect(run_obj.instance_variable_get(:@empty_fail_count)).to eq(0)
|
108
|
+
expect(stderr.string).to_not include("retrying the command.")
|
109
|
+
ensure
|
110
|
+
ENV["HATCHET_DISABLE_EMPTY_RUN_RETRY"] = original_env
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
@@ -0,0 +1,94 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe "ShellThrottle" do
|
4
|
+
before(:each) do
|
5
|
+
throttle = PlatformAPI.rate_throttle
|
6
|
+
def throttle.sleep(value)
|
7
|
+
# No sleep, faster tests
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
after(:each) do
|
12
|
+
throttle = PlatformAPI.rate_throttle
|
13
|
+
def throttle.sleep(value)
|
14
|
+
super # Unstub
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
describe "class unit test" do
|
19
|
+
before(:all) do
|
20
|
+
@platform_api = Hatchet::Runner.new("default_ruby").platform_api
|
21
|
+
end
|
22
|
+
|
23
|
+
it "throttles when throw is called" do
|
24
|
+
@count = 0
|
25
|
+
Hatchet::ShellThrottle.new(platform_api: @platform_api).call do
|
26
|
+
@count += 1
|
27
|
+
if @count >= 2
|
28
|
+
# No throttle
|
29
|
+
else
|
30
|
+
throw(:throttle)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
expect(@count).to eq(2)
|
34
|
+
end
|
35
|
+
|
36
|
+
it "does not throttle when throw is NOT called" do
|
37
|
+
@count = 0
|
38
|
+
Hatchet::ShellThrottle.new(platform_api: @platform_api).call do
|
39
|
+
@count += 1
|
40
|
+
end
|
41
|
+
expect(@count).to eq(1)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
describe "git push throttle" do
|
46
|
+
it "rate throttles `git push` " do
|
47
|
+
app = Hatchet::GitApp.new("default_ruby")
|
48
|
+
def app.git_push_heroku_yall
|
49
|
+
@_git_push_heroku_yall_call_count ||= 0
|
50
|
+
@_git_push_heroku_yall_call_count += 1
|
51
|
+
if @_git_push_heroku_yall_call_count >= 2
|
52
|
+
"Success"
|
53
|
+
else
|
54
|
+
raise Hatchet::App::FailedDeployError.new(
|
55
|
+
self,
|
56
|
+
"message",
|
57
|
+
output: "Your account reached the API rate limit Please wait a few minutes before making new requests"
|
58
|
+
)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def app.sleep_called?; @sleep_called; end
|
63
|
+
def app.what_is_git_push_heroku_yall_call_count; @_git_push_heroku_yall_call_count; end
|
64
|
+
|
65
|
+
app.push_without_retry!
|
66
|
+
|
67
|
+
expect(app.what_is_git_push_heroku_yall_call_count).to be(2)
|
68
|
+
end
|
69
|
+
|
70
|
+
it "rate throttles `git push` with different output" do
|
71
|
+
app = Hatchet::GitApp.new("default_ruby")
|
72
|
+
def app.git_push_heroku_yall
|
73
|
+
@_git_push_heroku_yall_call_count ||= 0
|
74
|
+
@_git_push_heroku_yall_call_count += 1
|
75
|
+
if @_git_push_heroku_yall_call_count >= 2
|
76
|
+
"Success"
|
77
|
+
else
|
78
|
+
raise Hatchet::App::FailedDeployError.new(
|
79
|
+
self,
|
80
|
+
"message",
|
81
|
+
output: "RPC failed; HTTP 429 curl 22 The requested URL returned error: 429 Too Many Requests"
|
82
|
+
)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def app.sleep_called?; @sleep_called; end
|
87
|
+
def app.what_is_git_push_heroku_yall_call_count; @_git_push_heroku_yall_call_count; end
|
88
|
+
|
89
|
+
app.push_without_retry!
|
90
|
+
|
91
|
+
expect(app.what_is_git_push_heroku_yall_call_count).to be(2)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
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
|
+
version: 7.3.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: 2020-09-
|
11
|
+
date: 2020-09-22 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: platform-api
|
@@ -192,6 +192,7 @@ files:
|
|
192
192
|
- lib/hatchet/app.rb
|
193
193
|
- lib/hatchet/config.rb
|
194
194
|
- lib/hatchet/git_app.rb
|
195
|
+
- lib/hatchet/heroku_run.rb
|
195
196
|
- lib/hatchet/init_project.rb
|
196
197
|
- lib/hatchet/reaper.rb
|
197
198
|
- lib/hatchet/reaper/app_age.rb
|
@@ -219,9 +220,10 @@ files:
|
|
219
220
|
- spec/hatchet/local_repo_spec.rb
|
220
221
|
- spec/hatchet/lock_spec.rb
|
221
222
|
- spec/spec_helper.rb
|
223
|
+
- spec/unit/heroku_run_spec.rb
|
222
224
|
- spec/unit/init_spec.rb
|
223
225
|
- spec/unit/reaper_spec.rb
|
224
|
-
- spec/unit/
|
226
|
+
- spec/unit/shell_throttle_spec.rb
|
225
227
|
- tmp/parallel_runtime_test.log
|
226
228
|
homepage: https://github.com/heroku/hatchet
|
227
229
|
licenses:
|
@@ -257,6 +259,7 @@ test_files:
|
|
257
259
|
- spec/hatchet/local_repo_spec.rb
|
258
260
|
- spec/hatchet/lock_spec.rb
|
259
261
|
- spec/spec_helper.rb
|
262
|
+
- spec/unit/heroku_run_spec.rb
|
260
263
|
- spec/unit/init_spec.rb
|
261
264
|
- spec/unit/reaper_spec.rb
|
262
|
-
- spec/unit/
|
265
|
+
- spec/unit/shell_throttle_spec.rb
|
data/spec/unit/shell_throttle.rb
DELETED
@@ -1,28 +0,0 @@
|
|
1
|
-
require "spec_helper"
|
2
|
-
|
3
|
-
describe "ShellThrottle" do
|
4
|
-
it "throttles when throw is called" do
|
5
|
-
platform_api = Hatchet::Runner.new("default_ruby").platform_api
|
6
|
-
|
7
|
-
@count = 0
|
8
|
-
Hatchet::ShellThrottle.new(platform_api: platform_api).call do
|
9
|
-
@count += 1
|
10
|
-
if @count >= 2
|
11
|
-
# No throttle
|
12
|
-
else
|
13
|
-
throw(:throttle)
|
14
|
-
end
|
15
|
-
end
|
16
|
-
expect(@count).to eq(2)
|
17
|
-
end
|
18
|
-
|
19
|
-
it "does not throttle when throw is NOT called" do
|
20
|
-
platform_api = Hatchet::Runner.new("default_ruby").platform_api
|
21
|
-
|
22
|
-
@count = 0
|
23
|
-
Hatchet::ShellThrottle.new(platform_api: platform_api).call do
|
24
|
-
@count += 1
|
25
|
-
end
|
26
|
-
expect(@count).to eq(1)
|
27
|
-
end
|
28
|
-
end
|