heroku_hatchet 7.2.0 → 7.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/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
|