sprinkle-tool/sprinkle

View on GitHub
lib/sprinkle/actors/ssh.rb

Summary

Maintainability
C
7 hrs
Test Coverage
require 'net/ssh/gateway'
require 'net/scp'
require File.dirname(__FILE__) + "/ssh/connection_cache"

module Sprinkle
  module Actors
    # The SSH actor requires no additional deployment tools other than the 
    # Ruby SSH libraries.
    #
    #   deployment do
    #     delivery :ssh do
    #       user "rails"
    #       password "leetz"
    #       port 2222
    #
    #       role :app, "app.myserver.com"
    #     end
    #   end
    #
    #
    # == Use ssh key file
    #
    #   deployment do
    #     delivery :ssh do
    #       user "sprinkle"
    #       keys "/path/to/ssh/key/file" # passed directly to Net::SSH as :keys option
    #
    #       role :app, "app.myserver.com"
    #     end
    #   end
    #
    #
    # == Working thru a gateway
    #
    # If you're behind a firewall and need to use a SSH gateway that's fine.
    # 
    #   deployment do
    #     delivery :ssh do
    #       gateway "work.sshgateway.com"
    #     end
    #   end
    class SSH < Actor
      attr_accessor :options #:nodoc:
      
      class SSHCommandFailure < StandardError #:nodoc:
        attr_accessor :details
      end
            
      def initialize(options = {}, &block) #:nodoc:
        @options = options.update(:user => 'root', :port => 22)
        @roles = {}
        self.instance_eval(&block) if block
        raise "You must define at least a single role." if @roles.empty?
      end

      # Define a whole host of roles at once
      #
      # This is depreciated - you should be using role instead.
      def roles(roles) #:nodoc:
        @roles = roles
      end
      
      # Determines if there are any servers for the given roles
      def servers_for_role?(roles) #:nodoc:
        roles=Array(roles)
        roles.any? { |r| @roles.keys.include? (r) }
      end

      # Define a role and add servers to it
      #   
      #   role :app, "app.server.com"
      #   role :db, "db.server.com"
      def role(role, server)
        @roles[role] ||= []
        @roles[role] << server
      end
      
      # Set an optional SSH gateway server - if set all outbound SSH traffic
      # will go thru this gateway
      def gateway(gateway)
        @options[:gateway] = gateway
      end
      
      # Set the SSH user
      def user(user)
        @options[:user] = user
      end

      # Set the SSH password
      def password(password)
        @options[:password] = password
      end

      # Set the SSH port
      def port(port)
        @options[:port] = port
      end

      def keys(keys)
        @options[:keys] = keys
      end

      # Set this to true to prepend 'sudo' to every command.
      def use_sudo(value=true)
        @options[:use_sudo] = value
      end
      
      def sudo? #:nodoc:
        @options[:use_sudo]
      end
      
      def sudo_command #:nodoc:
        "sudo"
      end
      
      def teardown #:nodoc:
        connections.shutdown!
      end
      
      def verify(verifier, roles) #:nodoc:
        # issue all the verification steps in a single SSH command
        commands=[prepare_commands(verifier.commands).join(" && ")]
        process(verifier.package.name, commands, roles)
      rescue SSHCommandFailure
        false
      end
      
      def install(installer, roles, opts = {}) #:nodoc:
        @installer = installer
        process(installer.package.name, installer.install_sequence, roles)
      rescue SSHCommandFailure => e
        raise_error(e)
      ensure
        @installer = nil
      end

      private
      
        def raise_error(e) #:nodoc:
          raise Sprinkle::Errors::RemoteCommandFailure.new(@installer, e.details, e)
        end
      
        def process(name, commands, roles) #:nodoc:
          execute_on_role(commands, roles)
        end      
      
        def execute_on_role(commands, role) #:nodoc:
          hosts = @roles[role]
          Array(hosts).each do |host|
            execute_on_host(commands, host)
          end
        end
        
        def prepare_commands(commands) #:nodoc:
          return commands unless sudo?
          commands.map do |command| 
            next command if command.is_a?(Symbol) || command.is_a?(Sprinkle::Commands::Command)
            command.match(/^#{sudo_command}/) ? command : "#{sudo_command} #{command}"
          end
        end
        
        def execute_on_host(commands,host) #:nodoc:
          
          prepare_commands(commands).each do |cmd|
            case cmd
              when Commands::Reconnect then
                reconnect host
              when Commands::Transfer then
                transfer_to_host(cmd.source, cmd.destination, host, 
                  :recursive => cmd.recursive?)
              else  
                run_command cmd, host
            end
          end
        end
        
        def run_command(cmd,host) #:nodoc:
          @log_recorder= Sprinkle::Utility::LogRecorder.new(cmd)
          session = ssh_session(host)
          logger.debug "[#{session.host}] ssh: #{cmd}"
          if channel_runner(session, cmd) != 0 
            fail=SSHCommandFailure.new
            fail.details = @log_recorder.hash.merge(:hosts => host)
            raise fail
          end
        end
        
        def channel_runner(session, command) #:nodoc:
          session.open_channel do |channel|
            channel.on_data do |ch, data|
              @log_recorder.log :out, data
              logger.debug yellow("[#{session.host}] stdout said-->\n#{data}\n")
            end
            channel.on_extended_data do |ch, type, data|
              next unless type == 1  # only handle stderr
              @log_recorder.log :err, data
              logger.debug red("[#{session.host}] stderr said -->\n#{data}\n")
            end

            channel.on_request("exit-status") do |ch, data|
              @log_recorder.code = data.read_long
              if @log_recorder.code == 0
                logger.debug(green 'success')
              else
                logger.debug(red('failed (%d).' % @log_recorder.code))
              end
            end

            channel.on_request("exit-signal") do |ch, data|
              logger.debug red("#{cmd} was signaled!: #{data.read_long}")
            end

            channel.exec command  do  |ch, status|
              logger.error("couldn't run remote command #{cmd}") unless status
              @log_recorder.code = -1
            end
          end
          session.loop
          @log_recorder.code
        end
        
        def transfer_to_role(source, destination, role, opts={}) #:nodoc:
          hosts = @roles[role]
          Array(hosts).each { |host| transfer_to_host(source, destination, host, opts) }
        end
        
        def transfer_to_host(source, destination, host, opts={}) #:nodoc:
          logger.debug "upload: #{destination}"
          session = ssh_session(host)
          scp = Net::SCP.new(session)
          scp.upload! source, destination, :recursive => opts[:recursive], :chunk_size => 32.kilobytes
        rescue RuntimeError => e
          if e.message =~ /Permission denied/
            raise Sprinkle::Errors::TransferFailure.no_permission(@installer,e)
          else
            raise e
          end          
        end
        
        def ssh_session(host) #:nodoc:
          connections.start(host, @options[:user], @options.slice(:password, :keys, :port))
        end       

        def reconnect(host) #:nodoc:
          connections.reconnect host
        end
        
        def connections #:nodoc:
          @connection_cache ||= SSHConnectionCache.new @options.slice(:gateway, :user)
        end 
        
        private
        def color(code, text)
          "\033[%sm%s\033[0m"%[code,text]
        end
        def red(text)
          color(31, text)
        end
        def yellow(text)
          color(33, text)
        end
        def green(text)
          color(32, text)
        end
        def blue(text)
          color(34, text)
        end
    end
  end
end