engineyard/engineyard

View on GitHub
lib/engineyard/serverside_runner.rb

Summary

Maintainability
A
3 hrs
Test Coverage
require 'escape'
require 'net/ssh'
require 'engineyard-serverside-adapter'

module EY
  class ServersideRunner
    def initialize(options)
      @verbose        = options[:verbose] || !!ENV['DEBUG']
      @hostname       = options[:bridge]
      env             = options[:environment]
      @adapter        = load_adapter(@hostname, options[:app], env, @verbose, options[:serverside_version])
      @username       = env.username
      @hierarchy_name = env.hierarchy_name
      @command        = nil
    end

    def deploy(&block)
      @command = @adapter.deploy(&block)
      self
    end

    def rollback(&block)
      @command = @adapter.rollback(&block)
      self
    end

    def restart(&block)
      @command = @adapter.restart(&block)
      self
    end

    def put_up_maintenance_page(&block)
      @command = @adapter.enable_maintenance(&block)
      self
    end

    def take_down_maintenance_page(&block)
      @command = @adapter.disable_maintenance(&block)
      self
    end

    def call(out, err)
      raise "No command!" unless @command
      @command.call do |cmd|
        run cmd, out, err
      end
    end

  private

    def load_adapter(bridge, app, environment, verbose, serverside_version)
      EY::Serverside::Adapter.new("/usr/local/ey_resin/ruby/bin") do |args|
        args.serverside_version = serverside_version
        args.app              = app.name
        args.git              = app.repository_uri
        args.instances        = instances_data(environment.deploy_to_instances, bridge)
        args.stack            = environment.app_server_stack_name
        args.framework_env    = environment.framework_env
        args.environment_name = environment.name
        args.account_name     = app.account.name
        args.verbose          = verbose
      end
    end

    # If we tell engineyard-serverside to use 'localhost', it'll run
    # commands on the instance directly (#system). If we give it the
    # instance's actual hostname, it'll SSH to itself.
    #
    # Using 'localhost' instead of its EC2 hostname speeds up
    # deploys on solos and single-app-server clusters significantly.
    def instances_data(instances, bridge)
      instances.map do |i|
        {
          hostname: i.hostname == bridge ? 'localhost' : i.hostname,
          roles:    [i.role],
          name:     i.name,
        }
      end
    end

    def run(remote_command, out, err)
      cmd = Escape.shell_command(['bash', '-lc', remote_command])

      if cmd.respond_to?(:encoding) && cmd.respond_to?(:force_encoding)
        out << "Encoding: #{cmd.encoding.name}" if @verbose
        cmd.force_encoding('binary')
        out << " => #{cmd.encoding.name}; __ENCODING__: #{__ENCODING__.name}; LANG: #{ENV['LANG']}; LC_CTYPE: #{ENV['LC_CTYPE']}\n" if @verbose
      end

      out << "Running command on #{@username}@#{@hostname}.\n"
      out << cmd << "\n" if @verbose || ENV['PRINT_CMD']

      if ENV["NO_SSH"]
        out << "NO_SSH is set. No output.\n"
        true
      else
        begin
          ssh(cmd, @hostname, @username, out, err)
        rescue Net::SSH::AuthenticationFailed
          raise EY::Error, <<-ERROR
Authentication Failed. Things to fix:
  1. Add your SSH key to your local SSH agent with `ssh-add path/to/key`.
  2. Add your SSH key to #{@hierarchy_name} on Engine Yard Cloud and apply the changes.
  (https://support.cloud.engineyard.com/entries/20996846-set-up-ssh-keys)
          ERROR
        end
      end
    end

    def net_ssh_options
      level = :fatal # default in Net::SSH
      if debug = ENV["DEBUG"]
        level = :info
        if %w[debug info warn error fatal].include?(debug.downcase)
          level = debug.downcase.to_sym
        end
      end

      {paranoid: false, verbose: level, keepalive: true, keepalive_interval: 60}
    end

    def ssh(cmd, hostname, username, out, err)
      exit_code = 1
      Net::SSH.start(hostname, username, net_ssh_options) do |net_ssh|
        net_ssh.open_channel do |channel|
          channel.exec cmd do |_, success|
            unless success
              err << "Remote command execution failed"
              return false
            end

            channel.on_data do |_, data|
              out << data
            end

            channel.on_extended_data do |_, _, data|
              err << data
            end

            channel.on_request("exit-status") do |_, data|
              exit_code = data.read_long
            end

            channel.on_request("exit-signal") do |_, data|
              exit_code = 255
            end

            # sending eof declares no more data coming from this end (close stdin)
            channel.eof!
          end
        end

        net_ssh.loop
      end
      exit_code.zero?
    end

  end
end