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: ENV["HATCHET_DEFAULT_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: ENV["HATCHET_DEFAULT_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



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

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

.default_buildpackObject



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

def self.default_buildpack
  @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



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

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)


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

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



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

def api_key
  @api_key ||= ENV['HEROKU_API_KEY'] ||= `heroku auth:token 2> /dev/null`.chomp
end

#api_rate_limitObject



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

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



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

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



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

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

#configObject



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

def config
  self.class.config
end

#couple_pipeline(app_name, pipeline_id) ⇒ Object



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

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



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

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



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

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

#create_sourceObject



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

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)


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

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

#delete_pipeline(pipeline_id) ⇒ Object



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

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



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

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)


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

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



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

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

#get_labsObject



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

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

#herokuObject



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

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

#in_directoryObject



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

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



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

def in_directory_fork(&block)
  Tempfile.create("stdout") do |tmp_file|
    pid = fork do
      $stdout.reopen(tmp_file, "a")
      $stderr.reopen(tmp_file, "a")
      $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?
      print File.read(tmp_file)
    else
      raise File.read(tmp_file)
    end
  end
end

#lab_is_installed?(lab) ⇒ Boolean

Returns:

  • (Boolean)


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

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

#not_debugging?Boolean Also known as: no_debug?

Returns:

  • (Boolean)


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

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



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

def output
  @output
end

#pipeline_idObject



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

def pipeline_id
  @pipeline_id
end

#platform_apiObject



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

def platform_api
  api_rate_limit
  return @platform_api
end

#pushObject Also known as: push!, push_with_retry



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

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)


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

def push_without_retry!
  raise NotImplementedError
end

#retry_error_message(error, attempt) ⇒ Object



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

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



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
201
# File 'lib/hatchet/app.rb', line 175

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



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
492
# File 'lib/hatchet/app.rb', line 457

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


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

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



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

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



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

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

#set_labs!Object



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

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

#setup!Object Also known as: setup

creates a new heroku app via the API



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

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



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

def source_get_url
  create_source
  @source_get_url
end

#teardown!Object



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

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



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

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