class Kitchen::SSH

Class to help establish SSH connections, issue remote commands, and transfer files between a local system and remote node.

@author Fletcher Nichol <fnichol@nichol.ca>

Constants

SOCKET_EXCEPTIONS

TCP socket exceptions

Attributes

hostname[R]

@return [String] the remote hostname @api private

logger[R]

@return [Logger] the logger to use @api private

options[R]

@return [Hash] SSH options, passed to `Net::SSH.start`

username[R]

@return [String] the username for the remote host @api private

Public Class Methods

new(hostname, username, options = {}) { |self| ... } click to toggle source

Constructs a new SSH object.

@example basic usage

ssh = Kitchen::SSH.new("remote.example.com", "root")
ssh.exec("sudo apt-get update")
ssh.upload!("/tmp/data.txt", "/var/lib/data.txt")
ssh.shutdown

@example block usage

Kitchen::SSH.new("remote.example.com", "root") do |ssh|
  ssh.exec("sudo apt-get update")
  ssh.upload!("/tmp/data.txt", "/var/lib/data.txt")
end

@param hostname [String] the remote hostname (IP address, FQDN, etc.) @param username [String] the username for the remote host @param options [Hash] configuration options @option options [Logger] :logger the logger to use

(default: `::Logger.new(STDOUT)`)

@yield [self] if a block is given then the constructed object yields

itself and calls `#shutdown` at the end, closing the remote connection
# File lib/kitchen/ssh.rb, line 62
def initialize(hostname, username, options = {})
  @hostname = hostname
  @username = username
  @options = options.dup
  @logger = @options.delete(:logger) || ::Logger.new(STDOUT)

  if block_given?
    yield self
    shutdown
  end
end

Public Instance Methods

exec(cmd) click to toggle source

Execute a command on the remote host.

@param cmd [String] command string to execute @raise [SSHFailed] if the command does not exit with a 0 code

# File lib/kitchen/ssh.rb, line 78
def exec(cmd)
  string_to_mask = "[SSH] #{self} (#{cmd})"
  masked_string = Util.mask_values(string_to_mask, %w{password ssh_http_proxy_password})
  logger.debug(masked_string)
  exit_code = exec_with_exit(cmd)

  if exit_code != 0
    raise SSHFailed, "SSH exited (#{exit_code}) for command: [#{cmd}]"
  end
end
login_command() click to toggle source

Builds a LoginCommand which can be used to open an interactive session on the remote host.

@return [LoginCommand] the login command

# File lib/kitchen/ssh.rb, line 160
def login_command
  args  = %w{ -o UserKnownHostsFile=/dev/null }
  args += %w{ -o StrictHostKeyChecking=no }
  args += %w{ -o IdentitiesOnly=yes } if options[:keys]
  args += %W{ -o LogLevel=#{logger.debug? ? "VERBOSE" : "ERROR"} }
  if options.key?(:forward_agent)
    args += %W{ -o ForwardAgent=#{options[:forward_agent] ? "yes" : "no"} }
  end
  Array(options[:keys]).each { |ssh_key| args += %W{ -i #{ssh_key} } }
  args += %W{ -p #{port} }
  args += %W{ #{username}@#{hostname} }

  LoginCommand.new("ssh", args)
end
shutdown() click to toggle source

Shuts down the session connection, if it is still active.

# File lib/kitchen/ssh.rb, line 140
def shutdown
  return if @session.nil?

  string_to_mask = "[SSH] closing connection to #{self}"
  masked_string = Util.mask_values(string_to_mask, %w{password ssh_http_proxy_password})
  logger.debug(masked_string)
  session.shutdown!
ensure
  @session = nil
end
upload(local, remote, options = {}, &progress) click to toggle source
# File lib/kitchen/ssh.rb, line 107
def upload(local, remote, options = {}, &progress)
  require "net/scp" unless defined?(Net::SCP)
  if progress.nil?
    progress = lambda do |_ch, name, sent, total|
      if sent == total
        logger.debug("Async Uploaded #{name} (#{total} bytes)")
      end
    end
  end

  session.scp.upload(local, remote, options, &progress)
end
upload!(local, remote, options = {}, &progress) click to toggle source

Uploads a local file to remote host.

@param local [String] path to local file @param remote [String] path to remote file destination @param options [Hash] configuration options that are passed to

`Net::SCP.upload`

@see net-ssh.github.io/net-scp/classes/Net/SCP.html#method-i-upload

# File lib/kitchen/ssh.rb, line 96
def upload!(local, remote, options = {}, &progress)
  require "net/scp" unless defined?(Net::SCP)
  if progress.nil?
    progress = lambda do |_ch, name, sent, total|
      logger.debug("Uploaded #{name} (#{total} bytes)") if sent == total
    end
  end

  session.scp.upload!(local, remote, options, &progress)
end
upload_path(local, remote, options = {}, &progress) click to toggle source
# File lib/kitchen/ssh.rb, line 134
def upload_path(local, remote, options = {}, &progress)
  options = { recursive: true }.merge(options)
  upload(local, remote, options, &progress)
end
upload_path!(local, remote, options = {}, &progress) click to toggle source

Uploads a recursive directory to remote host.

@param local [String] path to local file or directory @param remote [String] path to remote file destination @param options [Hash] configuration options that are passed to

`Net::SCP.upload`

@option options [true,false] :recursive recursive copy (default: `true`) @see net-ssh.github.io/net-scp/classes/Net/SCP.html#method-i-upload

# File lib/kitchen/ssh.rb, line 128
def upload_path!(local, remote, options = {}, &progress)
  options = { recursive: true }.merge(options)

  upload!(local, remote, options, &progress)
end
wait() click to toggle source

Blocks until the remote host's SSH TCP port is listening.

# File lib/kitchen/ssh.rb, line 152
def wait
  logger.info("Waiting for #{hostname}:#{port}...") until test_ssh
end

Private Instance Methods

establish_connection() click to toggle source

Establish a connection session to the remote host.

@return [Net::SSH::Connection::Session] the SSH connection session @api private

# File lib/kitchen/ssh.rb, line 211
def establish_connection
  rescue_exceptions = [
    Errno::EACCES, Errno::EADDRINUSE, Errno::ECONNREFUSED,
    Errno::ECONNRESET, Errno::ENETUNREACH, Errno::EHOSTUNREACH,
    Net::SSH::Disconnect, Net::SSH::AuthenticationFailed, Net::SSH::ConnectionTimeout
  ]
  retries = options[:ssh_retries] || 3

  begin
    string_to_mask = "[SSH] opening connection to #{self}"
    masked_string = Util.mask_values(string_to_mask, %w{password ssh_http_proxy_password})
    logger.debug(masked_string)
    Net::SSH.start(hostname, username, options)
  rescue *rescue_exceptions => e
    retries -= 1
    if retries > 0
      logger.info("[SSH] connection failed, retrying (#{e.inspect})")
      sleep options[:ssh_timeout] || 1
      retry
    else
      logger.warn("[SSH] connection failed, terminating (#{e.inspect})")
      raise
    end
  end
end
exec_with_exit(cmd) click to toggle source

Execute a remote command and return the command's exit code.

@param cmd [String] command string to execute @return [Integer] the exit code of the command @api private

# File lib/kitchen/ssh.rb, line 256
def exec_with_exit(cmd)
  exit_code = nil
  session.open_channel do |channel|
    channel.request_pty

    channel.exec(cmd) do |_ch, _success|
      channel.on_data do |_ch, data|
        logger << data
      end

      channel.on_extended_data do |_ch, _type, data|
        logger << data
      end

      channel.on_request("exit-status") do |_ch, data|
        exit_code = data.read_long
      end
    end
  end
  session.loop
  exit_code
end
port() click to toggle source

@return [Integer] SSH port (default: 22) @api private

# File lib/kitchen/ssh.rb, line 247
def port
  options.fetch(:port, 22)
end
session() click to toggle source

Builds the Net::SSH session connection or returns the existing one if built.

@return [Net::SSH::Connection::Session] the SSH connection session @api private

# File lib/kitchen/ssh.rb, line 203
def session
  @session ||= establish_connection
end
test_ssh() click to toggle source

Test a remote TCP socket (presumably SSH) for connectivity.

@return [true,false] a truthy value if the socket is ready and false

otherwise

@api private

# File lib/kitchen/ssh.rb, line 284
def test_ssh
  socket = TCPSocket.new(hostname, port)
  IO.select([socket], nil, nil, 5)
rescue *SOCKET_EXCEPTIONS
  sleep options[:ssh_timeout] || 2
  false
rescue Errno::EPERM, Errno::ETIMEDOUT
  false
ensure
  socket && socket.close
end
to_s() click to toggle source

String representation of object, reporting its connection details and configuration.

@api private

# File lib/kitchen/ssh.rb, line 241
def to_s
  "#{username}@#{hostname}:#{port}<#{options.inspect}>"
end