Class: Hatchet::App

Inherits:
Object
  • Object
show all
Defined in:
lib/hatchet/app.rb

Direct Known Subclasses

AnvilApp, GitApp

Defined Under Namespace

Classes: FailedDeploy, FailedDeployError, FailedReleaseError

Constant Summary collapse

HATCHET_BUILDPACK_BASE =
-> {
  ENV.fetch('HATCHET_BUILDPACK_BASE') {
    warn "ENV HATCHET_BUILDPACK_BASE is not set. It currently defaults to the ruby buildpack. In the future this env var will be required"
    "https://github.com/heroku/heroku-buildpack-ruby.git"
  }
}
HATCHET_BUILDPACK_BRANCH =
-> { ENV['HATCHET_BUILDPACK_BRANCH'] || ENV['HEROKU_TEST_RUN_BRANCH'] || Hatchet.git_branch }
SkipDefaultOption =
Object.new
DEFAULT_REPO_NAME =
Object.new
DefaultCommand =
Object.new

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(repo_name = DEFAULT_REPO_NAME, stack: "", name: default_name, debug: nil, debugging: nil, allow_failure: false, labs: [], buildpack: nil, buildpacks: nil, buildpack_url: nil, before_deploy: nil, run_multi: , retries: RETRIES, config: {}) ⇒ App

Returns a new instance of App.



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/hatchet/app.rb', line 51

def initialize(repo_name = DEFAULT_REPO_NAME,
               stack: "",
               name: default_name,
               debug: nil,
               debugging: nil,
               allow_failure: false,
               labs: [],
               buildpack: nil,
               buildpacks: nil,
               buildpack_url: nil,
               before_deploy: nil,
               run_multi: ENV["HATCHET_RUN_MULTI"],
               retries: RETRIES,
               config: {}
              )
  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
  @repo_name     = repo_name
  @directory     = self.config.path_for_name(@repo_name)
  @name          = name
  @stack         = stack
  @debug         = debug || debugging
  @allow_failure = allow_failure
  @labs          = ([] << labs).flatten.compact
  @buildpacks    = buildpack || buildpacks || buildpack_url || self.class.default_buildpack
  @buildpacks    = Array(@buildpacks)
  @buildpacks.map! {|b| b == :default ? self.class.default_buildpack : b}
  @run_multi = run_multi
  @max_retries_count = retries

  if run_multi && !ENV["HATCHET_EXPENSIVE_MODE"]
    raise "You're attempting to enable `run_multi: true` mode, but have not enabled `HATCHET_EXPENSIVE_MODE=1` env var to verify you understand the risks"
  end
  @run_multi_array = []
  @already_in_dir = nil
  @app_is_setup = nil

  @before_deploy_array = []
  @before_deploy_array << before_deploy if before_deploy
  @app_config    = config
  @reaper        = Reaper.new(api_rate_limit: api_rate_limit)
end

Instance Attribute Details

#app_configObject (readonly)

Returns the value of attribute app_config.



16
17
18
# File 'lib/hatchet/app.rb', line 16

def app_config
  @app_config
end

#buildpacksObject (readonly)

Returns the value of attribute buildpacks.



16
17
18
# File 'lib/hatchet/app.rb', line 16

def buildpacks
  @buildpacks
end

#max_retries_countObject (readonly)

Returns the value of attribute max_retries_count.



16
17
18
# File 'lib/hatchet/app.rb', line 16

def max_retries_count
  @max_retries_count
end

#nameObject (readonly)

Returns the value of attribute name.



16
17
18
# File 'lib/hatchet/app.rb', line 16

def name
  @name
end

#reaperObject (readonly)

Returns the value of attribute reaper.



16
17
18
# File 'lib/hatchet/app.rb', line 16

def reaper
  @reaper
end

#repo_nameObject (readonly)

Returns the value of attribute repo_name.



16
17
18
# File 'lib/hatchet/app.rb', line 16

def repo_name
  @repo_name
end

#stackObject (readonly)

Returns the value of attribute stack.



16
17
18
# File 'lib/hatchet/app.rb', line 16

def stack
  @stack
end

Class Method Details

.configObject

config is read only, should be threadsafe



124
125
126
# File 'lib/hatchet/app.rb', line 124

def self.config
  @config ||= Config.new
end

.default_buildpackObject



115
116
117
# File 'lib/hatchet/app.rb', line 115

def self.default_buildpack
  [HATCHET_BUILDPACK_BASE.call, HATCHET_BUILDPACK_BRANCH.call].join("#")
end

Instance Method Details

#add_database(plan_name = 'heroku-postgresql:dev', match_val = "HEROKU_POSTGRESQL_[A-Z]+_URL") ⇒ Object



162
163
164
165
166
167
168
169
# File 'lib/hatchet/app.rb', line 162

def add_database(plan_name = 'heroku-postgresql:dev', match_val = "HEROKU_POSTGRESQL_[A-Z]+_URL")
  max_retries_count.times.retry do
    # heroku.post_addon(name, plan_name)
    api_rate_limit.call.addon.create(name, plan: plan_name )
    _, value = get_config.detect {|k, v| k.match(/#{match_val}/) }
    set_config('DATABASE_URL' => value)
  end
end

#allow_failure?Boolean

Returns:

  • (Boolean)


119
120
121
# File 'lib/hatchet/app.rb', line 119

def allow_failure?
  @allow_failure
end

#annotate_failuresObject



99
100
101
102
103
# File 'lib/hatchet/app.rb', line 99

def annotate_failures
  yield
rescue *test_failure_classes => e
  raise e, "App: #{name} (#{@repo_name})\n#{e.message}"
end

#api_keyObject



448
449
450
# File 'lib/hatchet/app.rb', line 448

def api_key
  @api_key ||= ENV['HEROKU_API_KEY'] ||= `heroku auth:token`.chomp
end

#api_rate_limitObject



531
532
533
534
# File 'lib/hatchet/app.rb', line 531

def api_rate_limit
  @platform_api   ||= PlatformAPI.connect_oauth(api_key, cache: Moneta.new(:Null))
  @api_rate_limit ||= ApiRateLimit.new(@platform_api)
end

#before_deploy(behavior = :default, &block) ⇒ Object



326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
# File 'lib/hatchet/app.rb', line 326

def before_deploy(behavior = :default, &block)
  raise "block required" unless block

  case behavior
  when :default, :replace
    if @before_deploy_array.any? && behavior == :default
      STDERR.puts "Calling App#before_deploy multiple times will overwrite the contents. If you intended this: use `App#before_deploy(:replace)`"
      STDERR.puts "In the future, calling this method with no arguements will default to `App#before_deploy(:append)` behavior.\n#{caller.join("\n")}"
    end

    @before_deploy_array.clear
    @before_deploy_array << block
  when :prepend
    @before_deploy_array = [block] + @before_deploy_array
  when :append
    @before_deploy_array << block
  else
    raise "Unrecognized behavior: #{behavior.inspect}, valid inputs are :append, :prepend, and :replace"
  end

  self
end

#commit!Object



349
350
351
# File 'lib/hatchet/app.rb', line 349

def commit!
  local_cmd_exec!('git add .; git commit --allow-empty -m next')
end

#configObject



128
129
130
# File 'lib/hatchet/app.rb', line 128

def config
  self.class.config
end

#couple_pipeline(app_name, pipeline_id) ⇒ Object



501
502
503
# File 'lib/hatchet/app.rb', line 501

def couple_pipeline(app_name, pipeline_id)
  api_rate_limit.call.pipeline_coupling.create(app: app_name, pipeline: pipeline_id, stage: "development")
end

#create_appObject



279
280
281
282
283
284
285
286
287
288
289
290
# File 'lib/hatchet/app.rb', line 279

def create_app
  3.times.retry do
    begin
      hash = { name: name, stack: stack }
      hash.delete_if { |k,v| v.nil? }
      heroku_api_create_app(hash)
    rescue => e
      @reaper.cycle(app_exception_message: e.message)
      raise e
    end
  end
end

#create_pipelineObject



497
498
499
# File 'lib/hatchet/app.rb', line 497

def create_pipeline
  api_rate_limit.call.pipeline.create(name: @name)
end

#create_sourceObject



510
511
512
513
514
515
516
517
# File 'lib/hatchet/app.rb', line 510

def create_source
  @create_source ||= begin
    result = api_rate_limit.call.source.create
    @source_get_url = result["source_blob"]["get_url"]
    @source_put_url = result["source_blob"]["put_url"]
    @source_put_url
  end
end

#debug?Boolean Also known as: debugging?

set debug: true when creating app if you don’t want it to be automatically destroyed, useful for debugging…bad for app limits. turn on global debug by setting HATCHET_DEBUG=true in the env

Returns:

  • (Boolean)


265
266
267
# File 'lib/hatchet/app.rb', line 265

def debug?
  @debug || ENV['HATCHET_DEBUG'] || false
end

#delete_pipeline(pipeline_id) ⇒ Object



519
520
521
522
523
524
# File 'lib/hatchet/app.rb', line 519

def delete_pipeline(pipeline_id)
  api_rate_limit.call.pipeline.delete(pipeline_id)
rescue Excon::Error::Forbidden
  warn "Error deleting pipeline id: #{pipeline_id.inspect}, status: 403"
  # Means the pipeline likely doesn't exist, not sure why though
end

#deploy(&block) ⇒ Object



409
410
411
412
413
414
415
416
417
418
419
# File 'lib/hatchet/app.rb', line 409

def deploy(&block)
  in_directory do
    annotate_failures do
      in_dir_setup!
      self.push_with_retry!
      block.call(self, api_rate_limit.call, output) if block_given?
    end
  end
ensure
  self.teardown! if block_given?
end

#deployed?Boolean

Returns:

  • (Boolean)


275
276
277
# File 'lib/hatchet/app.rb', line 275

def deployed?
  api_rate_limit.call.formation.list(name).detect {|ps| ps["type"] == "web"}
end

#directoryObject



105
106
107
108
109
# File 'lib/hatchet/app.rb', line 105

def directory
  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."
  warn caller
  @directory
end

#get_configObject



139
140
141
142
# File 'lib/hatchet/app.rb', line 139

def get_config
  # heroku.get_config_vars(name).body
  api_rate_limit.call.config_var.info_for_app(name)
end

#get_labsObject



148
149
150
151
# File 'lib/hatchet/app.rb', line 148

def get_labs
  # heroku.get_features(name).body
  api_rate_limit.call.app_feature.list(name)
end

#herokuObject



452
453
454
# File 'lib/hatchet/app.rb', line 452

def heroku
  raise "Not supported, use `platform_api` instead."
end

#in_directoryObject



370
371
372
373
374
375
376
377
378
379
380
381
# File 'lib/hatchet/app.rb', line 370

def in_directory
  yield and return if @already_in_dir

  Dir.mktmpdir do |tmpdir|
    FileUtils.cp_r("#{original_source_code_directory}/.", "#{tmpdir}/.")
    Dir.chdir(tmpdir) do
      @already_in_dir = true
      yield
      @already_in_dir = false
    end
  end
end

#in_directory_fork(&block) ⇒ Object

A safer alternative to in_directory this method is used to run code that may mutate the current process anything run in this block is executed in a different fork



387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
# File 'lib/hatchet/app.rb', line 387

def in_directory_fork(&block)
  Tempfile.create("stdout") do |tmp_file|
    pid = fork do
      $stdout.reopen(tmp_file, "w")
      $stderr.reopen(tmp_file, "w")
      $stdout.sync = true
      $stderr.sync = true
      in_directory do |dir|
        yield dir
      end
      Kernel.exit!(0) # needed for https://github.com/seattlerb/minitest/pull/683
    end
    Process.waitpid(pid)

    if $?.success?
      puts File.read(tmp_file)
    else
      raise File.read(tmp_file)
    end
  end
end

#lab_is_installed?(lab) ⇒ Boolean

Returns:

  • (Boolean)


144
145
146
# File 'lib/hatchet/app.rb', line 144

def lab_is_installed?(lab)
  get_labs.any? {|hash| hash["name"] == lab }
end

#not_debugging?Boolean Also known as: no_debug?

Returns:

  • (Boolean)


270
271
272
# File 'lib/hatchet/app.rb', line 270

def not_debugging?
  !debug?
end

#original_source_code_directoryObject



111
112
113
# File 'lib/hatchet/app.rb', line 111

def original_source_code_directory
  @directory
end

#outputObject



444
445
446
# File 'lib/hatchet/app.rb', line 444

def output
  @output
end

#pipeline_idObject



493
494
495
# File 'lib/hatchet/app.rb', line 493

def pipeline_id
  @pipeline_id
end

#platform_apiObject



526
527
528
529
# File 'lib/hatchet/app.rb', line 526

def platform_api
  api_rate_limit
  return @platform_api
end

#pushObject Also known as: push!, push_with_retry



421
422
423
424
425
426
427
428
429
430
431
# File 'lib/hatchet/app.rb', line 421

def push
  retry_count = allow_failure? ? 1 : max_retries_count
  retry_count.times.retry do |attempt|
    begin
      @output = self.push_without_retry!
    rescue StandardError => error
      puts retry_error_message(error, attempt) unless retry_count == 1
      raise error
    end
  end
end

#push_without_retry!Object

Raises:

  • (NotImplementedError)


353
354
355
# File 'lib/hatchet/app.rb', line 353

def push_without_retry!
  raise NotImplementedError
end

#retry_error_message(error, attempt) ⇒ Object



436
437
438
439
440
441
442
# File 'lib/hatchet/app.rb', line 436

def retry_error_message(error, attempt)
  attempt += 1
  return "" if attempt == max_retries_count
  msg = "\nRetrying failed Attempt ##{attempt}/#{max_retries_count} to push for '#{name}' due to error: \n"<<
        "#{error.class} #{error.message}\n  #{error.backtrace.join("\n  ")}"
  return msg
end

#run(cmd_type, command = DefaultCommand, options = {}, &block) ⇒ Object

runs a command on heroku similar to ‘$ heroku run #foo` but programatically and with more control



174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/hatchet/app.rb', line 174

def run(cmd_type, command = DefaultCommand, options = {}, &block)
  case command
  when Hash
    options.merge!(command)
    command = cmd_type.to_s
  when nil
    STDERR.puts "Calling App#run with an explicit nil value in the second argument is deprecated."
    STDERR.puts "You can pass in a hash directly as the second argument now.\n#{caller.join("\n")}"
    command = cmd_type.to_s
  when DefaultCommand
    command = cmd_type.to_s
  else
    command = command.to_s
  end

  allow_run_multi! if @run_multi

  run_obj = Hatchet::HerokuRun.new(
    command,
    app: self,
    retry_on_empty: options.fetch(:retry_on_empty, !ENV["HATCHET_DISABLE_EMPTY_RUN_RETRY"]),
    heroku: options[:heroku],
    raw: options[:raw]
  ).call

  return run_obj.output
end

#run_ci(timeout: 900, &block) ⇒ Object



456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
# File 'lib/hatchet/app.rb', line 456

def run_ci(timeout: 900, &block)
  in_directory do
    max_retries_count.times.retry do
      result       = create_pipeline
      @pipeline_id = result["id"]
    end

    # When the CI run finishes, the associated ephemeral app created for the test run internally gets removed almost immediately
    # the system then sees a pipeline with no apps, and deletes it, also almost immediately
    # that would, with bad timing, mean our test run info poll in wait! would 403, and/or the delete_pipeline at the end
    # that's why we create an app explictly (or maybe it already exists), and then associate it with with the pipeline
    # the app will be auto cleaned up later
    in_dir_setup!
    max_retries_count.times.retry do
      couple_pipeline(@name, @pipeline_id)
    end

    test_run = TestRun.new(
      token:          api_key,
      buildpacks:     @buildpacks,
      timeout:        timeout,
      app:            self,
      pipeline:       @pipeline_id,
      api_rate_limit: api_rate_limit
    )

    max_retries_count.times.retry do
      test_run.create_test_run
    end
    test_run.wait!(&block)
  end
ensure
  teardown! if block_given?
  delete_pipeline(@pipeline_id) if @pipeline_id
  @pipeline_id = nil
end

#run_multi(command, options = {}, &block) ⇒ Object

Allows multiple commands to be run concurrently in the background.

WARNING! Using the feature requres that the underlying app is not on the “free” Heroku tier. This requires scaling up the dyno which is not free. If an app is scaled up and left in that state it can incur large costs.

Enabling this feature should be done with extreme caution.

Example:

Hatchet::Runner.new("default_ruby", run_multi: true)
  app.run_multi("ls") { |out| expect(out).to include("Gemfile") }
  app.run_multi("ruby -v") { |out| expect(out).to include("ruby") }
end

This example will run ‘heroku run ls` as well as `ruby -v` at the same time in the background. The return result will be yielded to the block after they finish running.

Order of execution is not guaranteed.

If you need to assert a command was successful, you can yield a second status object like this:

Hatchet::Runner.new("default_ruby", run_multi: true)
  app.run_multi("ls") do |out, status|
    expect(status.success?).to be_truthy
    expect(out).to include("Gemfile")
  end
  app.run_multi("ruby -v") do |out, status|
    expect(status.success?).to be_truthy
    expect(out).to include("ruby")
  end
end


240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
# File 'lib/hatchet/app.rb', line 240

def run_multi(command, options = {}, &block)
  raise "Block required" if block.nil?
  allow_run_multi!

  run_thread = Thread.new do
    run_obj = Hatchet::HerokuRun.new(
      command,
      app: self,
      retry_on_empty: options.fetch(:retry_on_empty, !ENV["HATCHET_DISABLE_EMPTY_RUN_RETRY"]),
      heroku: options[:heroku],
      raw: options[:raw]
    ).call

    yield run_obj.output, run_obj.status
  end
  run_thread.abort_on_exception = true

  @run_multi_array << run_thread

  true
end

#set_config(options = {}) ⇒ Object



132
133
134
135
136
137
# File 'lib/hatchet/app.rb', line 132

def set_config(options = {})
  options.each do |key, value|
    # heroku.put_config_vars(name, key => value)
    api_rate_limit.call.config_var.update(name, key => value)
  end
end

#set_lab(lab) ⇒ Object



157
158
159
160
# File 'lib/hatchet/app.rb', line 157

def set_lab(lab)
  # heroku.post_feature(lab, name)
  api_rate_limit.call.app_feature.update(name, lab, enabled: true)
end

#set_labs!Object



153
154
155
# File 'lib/hatchet/app.rb', line 153

def set_labs!
  @labs.each {|lab| set_lab(lab) }
end

#setup!Object Also known as: setup

creates a new heroku app via the API



302
303
304
305
306
307
308
309
310
311
312
313
# File 'lib/hatchet/app.rb', line 302

def setup!
  return self if @app_is_setup
  puts "Hatchet setup: #{name.inspect} for #{repo_name.inspect}"
  create_app
  set_labs!
  buildpack_list = @buildpacks.map { |pack| { buildpack: pack } }
  api_rate_limit.call.buildpack_installation.update(name, updates: buildpack_list)
  set_config @app_config

  @app_is_setup = true
  self
end

#source_get_urlObject



505
506
507
508
# File 'lib/hatchet/app.rb', line 505

def source_get_url
  create_source
  @source_get_url
end

#teardown!Object



357
358
359
360
361
362
363
364
365
366
367
368
# File 'lib/hatchet/app.rb', line 357

def teardown!
  return false unless @app_is_setup

  if @run_multi_is_setup
    @run_multi_array.map(&:join)
    platform_api.formation.update(name, "web", {"size" => "free"})
  end

ensure
  @app_update_info = platform_api.app.update(name, { maintenance: true }) if @app_is_setup
  @reaper.cycle if @app_is_setup
end

#update_stack(stack_name) ⇒ Object



296
297
298
299
# File 'lib/hatchet/app.rb', line 296

def update_stack(stack_name)
  @stack = stack_name
  api_rate_limit.call.app.update(name, build_stack: @stack)
end