class Hatchet::App

Constants

DEFAULT_REPO_NAME
DefaultCommand
HATCHET_BUILDPACK_BASE
HATCHET_BUILDPACK_BRANCH
SkipDefaultOption

Attributes

app_config[R]
buildpacks[R]
max_retries_count[R]
name[R]
reaper[R]
repo_name[R]
stack[R]

Public Class Methods

config() click to toggle source

config is read only, should be threadsafe

# File lib/hatchet/app.rb, line 126
def self.config
  @config ||= Config.new
end
default_buildpack() click to toggle source
# File lib/hatchet/app.rb, line 117
def self.default_buildpack
  @default_buildpack ||= [HATCHET_BUILDPACK_BASE.call, HATCHET_BUILDPACK_BRANCH.call].join("#")
end
new(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: {} ) click to toggle source
# 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

Public Instance Methods

add_database(plan_name = 'heroku-postgresql:dev', match_val = "HEROKU_POSTGRESQL_[A-Z]+_URL") click to toggle source
# 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?() click to toggle source
# File lib/hatchet/app.rb, line 121
def allow_failure?
  @allow_failure
end
annotate_failures() { || ... } click to toggle source
# 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_key() click to toggle source
# 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_limit() click to toggle source
# 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) click to toggle source
# 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!() click to toggle source
# File lib/hatchet/app.rb, line 370
def commit!
  local_cmd_exec!('git add .; git commit --allow-empty -m next')
end
config() click to toggle source
# File lib/hatchet/app.rb, line 130
def config
  self.class.config
end
couple_pipeline(app_name, pipeline_id) click to toggle source
# 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_app() click to toggle source
# 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_pipeline() click to toggle source
# File lib/hatchet/app.rb, line 514
def create_pipeline
  api_rate_limit.call.pipeline.create(name: @name)
end
create_source() click to toggle source
# 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?() click to toggle source

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

# File lib/hatchet/app.rb, line 267
def debug?
  @debug || ENV['HATCHET_DEBUG'] || false
end
Also aliased as: debugging?
debugging?()
Alias for: debug?
delete_pipeline(pipeline_id) click to toggle source
# 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) click to toggle source
# 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?() click to toggle source
# File lib/hatchet/app.rb, line 277
def deployed?
  api_rate_limit.call.formation.list(name).detect {|ps| ps["type"] == "web"}
end
directory() click to toggle source
# 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_config() click to toggle source
# 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_labs() click to toggle source
# File lib/hatchet/app.rb, line 150
def get_labs
  # heroku.get_features(name).body
  api_rate_limit.call.app_feature.list(name)
end
heroku() click to toggle source
# File lib/hatchet/app.rb, line 469
def heroku
  raise "Not supported, use `platform_api` instead."
end
in_directory() { |and return| ... } click to toggle source
# 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() { |dir| ... } click to toggle source

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

# 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) click to toggle source
# File lib/hatchet/app.rb, line 146
def lab_is_installed?(lab)
  get_labs.any? {|hash| hash["name"] == lab }
end
no_debug?()
Alias for: not_debugging?
not_debugging?() click to toggle source
# File lib/hatchet/app.rb, line 272
def not_debugging?
  !debug?
end
Also aliased as: no_debug?
original_source_code_directory() click to toggle source
# File lib/hatchet/app.rb, line 112
def original_source_code_directory
  @directory
end
output() click to toggle source
# File lib/hatchet/app.rb, line 461
def output
  @output
end
pipeline_id() click to toggle source
# File lib/hatchet/app.rb, line 510
def pipeline_id
  @pipeline_id
end
platform_api() click to toggle source
# File lib/hatchet/app.rb, line 543
def platform_api
  api_rate_limit
  return @platform_api
end
push() click to toggle source
# 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
Also aliased as: push!, push_with_retry
push!()
Alias for: push
push_with_retry()
Also aliased as: push_with_retry!
Alias for: push
push_with_retry!()
Alias for: push_with_retry
push_without_retry!() click to toggle source
# File lib/hatchet/app.rb, line 374
def push_without_retry!
  raise NotImplementedError
end
retry_error_message(error, attempt) click to toggle source
# 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) click to toggle source

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

# 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) click to toggle source
# 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 = {}) { |output, status| ... } click to toggle source

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
# 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 = {}) click to toggle source
# 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) click to toggle source
# 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!() click to toggle source
# File lib/hatchet/app.rb, line 155
def set_labs!
  @labs.each {|lab| set_lab(lab) }
end
setup()
Alias for: setup!
setup!() click to toggle source

creates a new heroku app via the API

# 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
Also aliased as: setup
source_get_url() click to toggle source
# File lib/hatchet/app.rb, line 522
def source_get_url
  create_source
  @source_get_url
end
teardown!() click to toggle source
# 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) click to toggle source
# 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

Private Instance Methods

allow_run_multi!() click to toggle source
# File lib/hatchet/app.rb, line 204
        def allow_run_multi!
  raise "Must explicitly enable the `run_multi: true` option. This requires scaling up a dyno and is not free, it may incur charges on your account" unless @run_multi

  @run_multi_is_setup ||= platform_api.formation.update(name, "web", {"size" => "Standard-1X"})
end
call_before_deploy() click to toggle source
# File lib/hatchet/app.rb, line 579
        def call_before_deploy
  return unless @before_deploy_array.any?

  @before_deploy_array.each do |block|
    raise "before_deploy: #{block.inspect} must respond to :call"  unless block.respond_to?(:call)
    raise "before_deploy: #{block.inspect} must respond to :arity" unless block.respond_to?(:arity)

    if block.arity == 1
      block.call(self)
    else
      block.call
    end
  end

  commit! if needs_commit?
end
create_git_repo!() click to toggle source
# File lib/hatchet/app.rb, line 571
        def create_git_repo!
  local_cmd_exec!('git init; git add .; git commit -m "init"')
end
default_name() click to toggle source
# File lib/hatchet/app.rb, line 575
        def default_name
  "#{Hatchet::APP_PREFIX}#{SecureRandom.hex(5)}"
end
heroku_api_create_app(hash) click to toggle source
# File lib/hatchet/app.rb, line 314
        def heroku_api_create_app(hash)
  api_rate_limit.call.app.create(hash)
end
in_dir_setup!() click to toggle source
# File lib/hatchet/app.rb, line 337
        def in_dir_setup!
  setup!
  raise "Error you're in #{Dir.pwd} and might accidentally modify your disk contents" unless @already_in_dir
  @in_dir_setup ||= begin
    create_git_repo! unless is_git_repo?
    call_before_deploy
    true
  end
end
is_git_repo?() click to toggle source
# File lib/hatchet/app.rb, line 560
        def is_git_repo?
  `git rev-parse --git-dir > /dev/null 2>&1`
  $?.success?
end
local_cmd_exec!(cmd) click to toggle source
# File lib/hatchet/app.rb, line 565
        def local_cmd_exec!(cmd)
  out = `#{cmd}`
  raise "Command: #{cmd} failed: #{out}" unless $?.success?
  out
end
needs_commit?() click to toggle source
# File lib/hatchet/app.rb, line 553
        def needs_commit?
  out = local_cmd_exec!('git status --porcelain').chomp

  return false if out.empty?
  true
end
test_failure_classes() click to toggle source
# File lib/hatchet/app.rb, line 94
        def test_failure_classes
  class_array = []
  class_array << RSpec::Expectations::ExpectationNotMetError if defined?(RSpec::Expectations::ExpectationNotMetError)
  class_array
end