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'] || 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
92
# 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
  @heroku_id     = nil
  @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
  @outer_deploy_block = nil

  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

  @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



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

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

.default_buildpackObject



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

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



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

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)


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

def allow_failure?
  @allow_failure
end

#annotate_failuresObject



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

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

#api_keyObject



465
466
467
# File 'lib/hatchet/app.rb', line 465

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

#api_rate_limitObject



548
549
550
551
# File 'lib/hatchet/app.rb', line 548

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



347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
# File 'lib/hatchet/app.rb', line 347

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



370
371
372
# File 'lib/hatchet/app.rb', line 370

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

#configObject



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

def config
  self.class.config
end

#couple_pipeline(app_name, pipeline_id) ⇒ Object



518
519
520
# File 'lib/hatchet/app.rb', line 518

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



281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
# File 'lib/hatchet/app.rb', line 281

def create_app
  3.times.retry do
    begin
      # Remove any obviously old apps first
      # Try to use existing cache of apps to
      # minimize API calls
      @reaper.destroy_older_apps(
        force_refresh: false,
        on_conflict: :stop_if_under_limit,
      )
      hash = { name: name, stack: stack }
      hash.delete_if { |k,v| v.nil? }
      result = heroku_api_create_app(hash)
      @heroku_id = result["id"]
    rescue => e
      # If we can't create an app assume
      # it might be due to resource constraints
      #
      # Try to delete existing apps
      @reaper.destroy_older_apps(
        force_refresh: true,
        on_conflict: :stop_if_under_limit,
      )
      # If we're still not under the limit, sleep a bit
      # retry later.
      @reaper.sleep_if_over_limit(
        reason: "Could not create app #{e.message}"
      )
      raise e
    end
  end
end

#create_pipelineObject



514
515
516
# File 'lib/hatchet/app.rb', line 514

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

#create_sourceObject



527
528
529
530
531
532
533
534
# File 'lib/hatchet/app.rb', line 527

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)


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

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

#delete_pipeline(pipeline_id) ⇒ Object



536
537
538
539
540
541
# File 'lib/hatchet/app.rb', line 536

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



425
426
427
428
429
430
431
432
433
434
435
436
# File 'lib/hatchet/app.rb', line 425

def deploy(&block)
  in_directory do
    annotate_failures do
      @outer_deploy_block ||= block # deploy! can be called multiple times. Only teardown once
      in_dir_setup!
      push_with_retry!
      block.call(self, api_rate_limit.call, output) if block_given?
    end
  end
ensure
  self.teardown! if block_given? && @outer_deploy_block == block
end

#deployed?Boolean

Returns:

  • (Boolean)


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

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

#directoryObject



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

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



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

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

#get_labsObject



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

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

#herokuObject



469
470
471
# File 'lib/hatchet/app.rb', line 469

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

#in_directoryObject



386
387
388
389
390
391
392
393
394
395
396
397
# File 'lib/hatchet/app.rb', line 386

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



403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
# File 'lib/hatchet/app.rb', line 403

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)


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

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

#not_debugging?Boolean Also known as: no_debug?

Returns:

  • (Boolean)


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

def not_debugging?
  !debug?
end

#original_source_code_directoryObject



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

def original_source_code_directory
  @directory
end

#outputObject



461
462
463
# File 'lib/hatchet/app.rb', line 461

def output
  @output
end

#pipeline_idObject



510
511
512
# File 'lib/hatchet/app.rb', line 510

def pipeline_id
  @pipeline_id
end

#platform_apiObject



543
544
545
546
# File 'lib/hatchet/app.rb', line 543

def platform_api
  api_rate_limit
  return @platform_api
end

#pushObject Also known as: push!, push_with_retry



438
439
440
441
442
443
444
445
446
447
448
# File 'lib/hatchet/app.rb', line 438

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)


374
375
376
# File 'lib/hatchet/app.rb', line 374

def push_without_retry!
  raise NotImplementedError
end

#retry_error_message(error, attempt) ⇒ Object



453
454
455
456
457
458
459
# File 'lib/hatchet/app.rb', line 453

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



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

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



473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
# File 'lib/hatchet/app.rb', line 473

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


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

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



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

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



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

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

#set_labs!Object



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

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

#setup!Object Also known as: setup

creates a new heroku app via the API



324
325
326
327
328
329
330
331
332
333
334
# File 'lib/hatchet/app.rb', line 324

def setup!
  return self if @heroku_id
  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

  self
end

#source_get_urlObject



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

def source_get_url
  create_source
  @source_get_url
end

#teardown!Object



378
379
380
381
382
383
384
# File 'lib/hatchet/app.rb', line 378

def teardown!
    @run_multi_array.map(&:join)
ensure
  if @heroku_id && !ENV["HEROKU_DEBUG_EXPENSIVE"]
    @reaper.destroy_with_log(name: @name, id: @heroku_id, reason: "teardown")
  end
end

#update_stack(stack_name) ⇒ Object



318
319
320
321
# File 'lib/hatchet/app.rb', line 318

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