lib/kitchen/transport/ssh.rb in test-kitchen-1.10.1 vs lib/kitchen/transport/ssh.rb in test-kitchen-1.10.2.dev

- old
+ new

@@ -1,386 +1,386 @@ -# -*- encoding: utf-8 -*- -# -# Author:: Fletcher Nichol (<fnichol@nichol.ca>) -# -# Copyright (C) 2014, Fletcher Nichol -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -require "kitchen" - -require "net/ssh" -require "net/scp" -require "timeout" -require "benchmark" - -module Kitchen - - module Transport - - # Wrapped exception for any internally raised SSH-related errors. - # - # @author Fletcher Nichol <fnichol@nichol.ca> - class SshFailed < TransportFailed; end - - # A Transport which uses the SSH protocol to execute commands and transfer - # files. - # - # @author Fletcher Nichol <fnichol@nichol.ca> - class Ssh < Kitchen::Transport::Base - - kitchen_transport_api_version 1 - - plugin_version Kitchen::VERSION - - default_config :port, 22 - default_config :username, "root" - default_config :keepalive, true - default_config :keepalive_interval, 60 - # needs to be one less than the configured sshd_config MaxSessions - default_config :max_ssh_sessions, 9 - default_config :connection_timeout, 15 - default_config :connection_retries, 5 - default_config :connection_retry_sleep, 1 - default_config :max_wait_until_ready, 600 - - default_config :ssh_key, nil - expand_path_for :ssh_key - - # compression disabled by default for speed - default_config :compression, false - required_config :compression - - default_config :compression_level do |transport| - transport[:compression] == false ? 0 : 6 - end - - def finalize_config!(instance) - super - - # zlib was never a valid value and breaks in net-ssh >= 2.10 - # TODO: remove these backwards compatiable casts in 2.0 - case config[:compression] - when "zlib" - config[:compression] = "zlib@openssh.com" - when "none" - config[:compression] = false - end - - self - end - - # (see Base#connection) - def connection(state, &block) - options = connection_options(config.to_hash.merge(state)) - - if @connection && @connection_options == options - reuse_connection(&block) - else - create_new_connection(options, &block) - end - end - - # (see Base#cleanup!) - def cleanup! - if @connection - logger.debug("[SSH] shutting previous connection #{@connection}") - @connection.close - @connection = @connection_options = nil - end - end - - # A Connection instance can be generated and re-generated, given new - # connection details such as connection port, hostname, credentials, etc. - # This object is responsible for carrying out the actions on the remote - # host such as executing commands, transferring files, etc. - # - # @author Fletcher Nichol <fnichol@nichol.ca> - class Connection < Kitchen::Transport::Base::Connection - - # (see Base::Connection#close) - def close - return if @session.nil? - - logger.debug("[SSH] closing connection to #{self}") - session.close - ensure - @session = nil - end - - # (see Base::Connection#execute) - def execute(command) - return if command.nil? - logger.debug("[SSH] #{self} (#{command})") - exit_code = execute_with_exit_code(command) - - if exit_code != 0 - raise Transport::SshFailed.new( - "SSH exited (#{exit_code}) for command: [#{command}]", - exit_code - ) - end - rescue Net::SSH::Exception => ex - raise SshFailed, "SSH command failed (#{ex.message})" - end - - # (see Base::Connection#login_command) - 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 - - # (see Base::Connection#upload) - def upload(locals, remote) - logger.debug("TIMING: scp async upload (Kitchen::Transport::Ssh)") - elapsed = Benchmark.measure do - waits = [] - Array(locals).map do |local| - opts = File.directory?(local) ? { :recursive => true } : {} - - waits.push session.scp.upload(local, remote, opts) do |_ch, name, sent, total| - logger.debug("Async Uploaded #{name} (#{total} bytes)") if sent == total - end - waits.shift.wait while waits.length >= max_ssh_sessions - end - waits.each(&:wait) - end - delta = Util.duration(elapsed.real) - logger.debug("TIMING: scp async upload (Kitchen::Transport::Ssh) took #{delta}") - rescue Net::SSH::Exception => ex - raise SshFailed, "SCP upload failed (#{ex.message})" - end - - # (see Base::Connection#wait_until_ready) - def wait_until_ready - delay = 3 - session( - :retries => max_wait_until_ready / delay, - :delay => delay, - :message => "Waiting for SSH service on #{hostname}:#{port}, " \ - "retrying in #{delay} seconds" - ) - execute(PING_COMMAND.dup) - end - - private - - PING_COMMAND = "echo '[SSH] Established'".freeze - - RESCUE_EXCEPTIONS_ON_ESTABLISH = [ - Errno::EACCES, Errno::EADDRINUSE, Errno::ECONNREFUSED, Errno::ETIMEDOUT, - Errno::ECONNRESET, Errno::ENETUNREACH, Errno::EHOSTUNREACH, - Net::SSH::Disconnect, Net::SSH::AuthenticationFailed, Net::SSH::ConnectionTimeout, - Timeout::Error - ].freeze - - # @return [Integer] cap on number of parallel ssh sessions we can use - # @api private - attr_reader :max_ssh_sessions - - # @return [Integer] how many times to retry when failing to execute - # a command or transfer files - # @api private - attr_reader :connection_retries - - # @return [Float] how many seconds to wait before attempting a retry - # when failing to execute a command or transfer files - # @api private - attr_reader :connection_retry_sleep - - # @return [String] the hostname or IP address of the remote SSH host - # @api private - attr_reader :hostname - - # @return [Integer] how many times to retry when invoking - # `#wait_until_ready` before failing - # @api private - attr_reader :max_wait_until_ready - - # @return [String] the username to use when connecting to the remote - # SSH host - # @api private - attr_reader :username - - # @return [Integer] the TCP port number to use when connecting to the - # remote SSH host - # @api private - attr_reader :port - - # Establish an SSH session on the remote host. - # - # @param opts [Hash] retry options - # @option opts [Integer] :retries the number of times to retry before - # failing - # @option opts [Float] :delay the number of seconds to wait until - # attempting a retry - # @option opts [String] :message an optional message to be logged on - # debug (overriding the default) when a rescuable exception is raised - # @return [Net::SSH::Connection::Session] the SSH connection session - # @api private - def establish_connection(opts) - logger.debug("[SSH] opening connection to #{self}") - Net::SSH.start(hostname, username, options) - rescue *RESCUE_EXCEPTIONS_ON_ESTABLISH => e - if (opts[:retries] -= 1) > 0 - message = if opts[:message] - logger.debug("[SSH] connection failed (#{e.inspect})") - opts[:message] - else - "[SSH] connection failed, retrying in #{opts[:delay]} seconds " \ - "(#{e.inspect})" - end - logger.info(message) - sleep(opts[:delay]) - retry - else - logger.warn("[SSH] connection failed, terminating (#{e.inspect})") - raise SshFailed, "SSH session could not be established" - end - end - - # Execute a remote command over SSH and return the command's exit code. - # - # @param command [String] command string to execute - # @return [Integer] the exit code of the command - # @api private - def execute_with_exit_code(command) - exit_code = nil - session.open_channel do |channel| - - channel.request_pty - - channel.exec(command) 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 - - # (see Base::Connection#init_options) - def init_options(options) - super - @username = @options.delete(:username) - @hostname = @options.delete(:hostname) - @port = @options[:port] # don't delete from options - @connection_retries = @options.delete(:connection_retries) - @connection_retry_sleep = @options.delete(:connection_retry_sleep) - @max_ssh_sessions = @options.delete(:max_ssh_sessions) - @max_wait_until_ready = @options.delete(:max_wait_until_ready) - end - - # Returns a connection session, or establishes one when invoked the - # first time. - # - # @param retry_options [Hash] retry options for the initial connection - # @return [Net::SSH::Connection::Session] the SSH connection session - # @api private - def session(retry_options = {}) - @session ||= establish_connection({ - :retries => connection_retries.to_i, - :delay => connection_retry_sleep.to_i - }.merge(retry_options)) - end - - # String representation of object, reporting its connection details and - # configuration. - # - # @api private - def to_s - "#{username}@#{hostname}<#{options.inspect}>" - end - end - - private - - # Builds the hash of options needed by the Connection object on - # construction. - # - # @param data [Hash] merged configuration and mutable state data - # @return [Hash] hash of connection options - # @api private - # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity - def connection_options(data) - opts = { - :logger => logger, - :user_known_hosts_file => "/dev/null", - :paranoid => false, - :hostname => data[:hostname], - :port => data[:port], - :username => data[:username], - :compression => data[:compression], - :compression_level => data[:compression_level], - :keepalive => data[:keepalive], - :keepalive_interval => data[:keepalive_interval], - :timeout => data[:connection_timeout], - :connection_retries => data[:connection_retries], - :connection_retry_sleep => data[:connection_retry_sleep], - :max_ssh_sessions => data[:max_ssh_sessions], - :max_wait_until_ready => data[:max_wait_until_ready] - } - - opts[:keys_only] = true if data[:ssh_key] - opts[:keys] = Array(data[:ssh_key]) if data[:ssh_key] - opts[:auth_methods] = ["publickey"] if data[:ssh_key] - opts[:password] = data[:password] if data.key?(:password) - opts[:forward_agent] = data[:forward_agent] if data.key?(:forward_agent) - opts[:verbose] = data[:verbose].to_sym if data.key?(:verbose) - - opts - end - - # Creates a new SSH Connection instance and save it for potential future - # reuse. - # - # @param options [Hash] conneciton options - # @return [Ssh::Connection] an SSH Connection instance - # @api private - def create_new_connection(options, &block) - cleanup! - @connection_options = options - @connection = Kitchen::Transport::Ssh::Connection.new(options, &block) - end - - # Return the last saved SSH connection instance. - # - # @return [Ssh::Connection] an SSH Connection instance - # @api private - def reuse_connection - logger.debug("[SSH] reusing existing connection #{@connection}") - yield @connection if block_given? - @connection - end - end - end -end +# -*- encoding: utf-8 -*- +# +# Author:: Fletcher Nichol (<fnichol@nichol.ca>) +# +# Copyright (C) 2014, Fletcher Nichol +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "kitchen" + +require "net/ssh" +require "net/scp" +require "timeout" +require "benchmark" + +module Kitchen + + module Transport + + # Wrapped exception for any internally raised SSH-related errors. + # + # @author Fletcher Nichol <fnichol@nichol.ca> + class SshFailed < TransportFailed; end + + # A Transport which uses the SSH protocol to execute commands and transfer + # files. + # + # @author Fletcher Nichol <fnichol@nichol.ca> + class Ssh < Kitchen::Transport::Base + + kitchen_transport_api_version 1 + + plugin_version Kitchen::VERSION + + default_config :port, 22 + default_config :username, "root" + default_config :keepalive, true + default_config :keepalive_interval, 60 + # needs to be one less than the configured sshd_config MaxSessions + default_config :max_ssh_sessions, 9 + default_config :connection_timeout, 15 + default_config :connection_retries, 5 + default_config :connection_retry_sleep, 1 + default_config :max_wait_until_ready, 600 + + default_config :ssh_key, nil + expand_path_for :ssh_key + + # compression disabled by default for speed + default_config :compression, false + required_config :compression + + default_config :compression_level do |transport| + transport[:compression] == false ? 0 : 6 + end + + def finalize_config!(instance) + super + + # zlib was never a valid value and breaks in net-ssh >= 2.10 + # TODO: remove these backwards compatiable casts in 2.0 + case config[:compression] + when "zlib" + config[:compression] = "zlib@openssh.com" + when "none" + config[:compression] = false + end + + self + end + + # (see Base#connection) + def connection(state, &block) + options = connection_options(config.to_hash.merge(state)) + + if @connection && @connection_options == options + reuse_connection(&block) + else + create_new_connection(options, &block) + end + end + + # (see Base#cleanup!) + def cleanup! + if @connection + logger.debug("[SSH] shutting previous connection #{@connection}") + @connection.close + @connection = @connection_options = nil + end + end + + # A Connection instance can be generated and re-generated, given new + # connection details such as connection port, hostname, credentials, etc. + # This object is responsible for carrying out the actions on the remote + # host such as executing commands, transferring files, etc. + # + # @author Fletcher Nichol <fnichol@nichol.ca> + class Connection < Kitchen::Transport::Base::Connection + + # (see Base::Connection#close) + def close + return if @session.nil? + + logger.debug("[SSH] closing connection to #{self}") + session.close + ensure + @session = nil + end + + # (see Base::Connection#execute) + def execute(command) + return if command.nil? + logger.debug("[SSH] #{self} (#{command})") + exit_code = execute_with_exit_code(command) + + if exit_code != 0 + raise Transport::SshFailed.new( + "SSH exited (#{exit_code}) for command: [#{command}]", + exit_code + ) + end + rescue Net::SSH::Exception => ex + raise SshFailed, "SSH command failed (#{ex.message})" + end + + # (see Base::Connection#login_command) + 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 + + # (see Base::Connection#upload) + def upload(locals, remote) + logger.debug("TIMING: scp async upload (Kitchen::Transport::Ssh)") + elapsed = Benchmark.measure do + waits = [] + Array(locals).map do |local| + opts = File.directory?(local) ? { :recursive => true } : {} + + waits.push session.scp.upload(local, remote, opts) do |_ch, name, sent, total| + logger.debug("Async Uploaded #{name} (#{total} bytes)") if sent == total + end + waits.shift.wait while waits.length >= max_ssh_sessions + end + waits.each(&:wait) + end + delta = Util.duration(elapsed.real) + logger.debug("TIMING: scp async upload (Kitchen::Transport::Ssh) took #{delta}") + rescue Net::SSH::Exception => ex + raise SshFailed, "SCP upload failed (#{ex.message})" + end + + # (see Base::Connection#wait_until_ready) + def wait_until_ready + delay = 3 + session( + :retries => max_wait_until_ready / delay, + :delay => delay, + :message => "Waiting for SSH service on #{hostname}:#{port}, " \ + "retrying in #{delay} seconds" + ) + execute(PING_COMMAND.dup) + end + + private + + PING_COMMAND = "echo '[SSH] Established'".freeze + + RESCUE_EXCEPTIONS_ON_ESTABLISH = [ + Errno::EACCES, Errno::EADDRINUSE, Errno::ECONNREFUSED, Errno::ETIMEDOUT, + Errno::ECONNRESET, Errno::ENETUNREACH, Errno::EHOSTUNREACH, + Net::SSH::Disconnect, Net::SSH::AuthenticationFailed, Net::SSH::ConnectionTimeout, + Timeout::Error + ].freeze + + # @return [Integer] cap on number of parallel ssh sessions we can use + # @api private + attr_reader :max_ssh_sessions + + # @return [Integer] how many times to retry when failing to execute + # a command or transfer files + # @api private + attr_reader :connection_retries + + # @return [Float] how many seconds to wait before attempting a retry + # when failing to execute a command or transfer files + # @api private + attr_reader :connection_retry_sleep + + # @return [String] the hostname or IP address of the remote SSH host + # @api private + attr_reader :hostname + + # @return [Integer] how many times to retry when invoking + # `#wait_until_ready` before failing + # @api private + attr_reader :max_wait_until_ready + + # @return [String] the username to use when connecting to the remote + # SSH host + # @api private + attr_reader :username + + # @return [Integer] the TCP port number to use when connecting to the + # remote SSH host + # @api private + attr_reader :port + + # Establish an SSH session on the remote host. + # + # @param opts [Hash] retry options + # @option opts [Integer] :retries the number of times to retry before + # failing + # @option opts [Float] :delay the number of seconds to wait until + # attempting a retry + # @option opts [String] :message an optional message to be logged on + # debug (overriding the default) when a rescuable exception is raised + # @return [Net::SSH::Connection::Session] the SSH connection session + # @api private + def establish_connection(opts) + logger.debug("[SSH] opening connection to #{self}") + Net::SSH.start(hostname, username, options) + rescue *RESCUE_EXCEPTIONS_ON_ESTABLISH => e + if (opts[:retries] -= 1) > 0 + message = if opts[:message] + logger.debug("[SSH] connection failed (#{e.inspect})") + opts[:message] + else + "[SSH] connection failed, retrying in #{opts[:delay]} seconds " \ + "(#{e.inspect})" + end + logger.info(message) + sleep(opts[:delay]) + retry + else + logger.warn("[SSH] connection failed, terminating (#{e.inspect})") + raise SshFailed, "SSH session could not be established" + end + end + + # Execute a remote command over SSH and return the command's exit code. + # + # @param command [String] command string to execute + # @return [Integer] the exit code of the command + # @api private + def execute_with_exit_code(command) + exit_code = nil + session.open_channel do |channel| + + channel.request_pty + + channel.exec(command) 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 + + # (see Base::Connection#init_options) + def init_options(options) + super + @username = @options.delete(:username) + @hostname = @options.delete(:hostname) + @port = @options[:port] # don't delete from options + @connection_retries = @options.delete(:connection_retries) + @connection_retry_sleep = @options.delete(:connection_retry_sleep) + @max_ssh_sessions = @options.delete(:max_ssh_sessions) + @max_wait_until_ready = @options.delete(:max_wait_until_ready) + end + + # Returns a connection session, or establishes one when invoked the + # first time. + # + # @param retry_options [Hash] retry options for the initial connection + # @return [Net::SSH::Connection::Session] the SSH connection session + # @api private + def session(retry_options = {}) + @session ||= establish_connection({ + :retries => connection_retries.to_i, + :delay => connection_retry_sleep.to_i + }.merge(retry_options)) + end + + # String representation of object, reporting its connection details and + # configuration. + # + # @api private + def to_s + "#{username}@#{hostname}<#{options.inspect}>" + end + end + + private + + # Builds the hash of options needed by the Connection object on + # construction. + # + # @param data [Hash] merged configuration and mutable state data + # @return [Hash] hash of connection options + # @api private + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity + def connection_options(data) + opts = { + :logger => logger, + :user_known_hosts_file => "/dev/null", + :paranoid => false, + :hostname => data[:hostname], + :port => data[:port], + :username => data[:username], + :compression => data[:compression], + :compression_level => data[:compression_level], + :keepalive => data[:keepalive], + :keepalive_interval => data[:keepalive_interval], + :timeout => data[:connection_timeout], + :connection_retries => data[:connection_retries], + :connection_retry_sleep => data[:connection_retry_sleep], + :max_ssh_sessions => data[:max_ssh_sessions], + :max_wait_until_ready => data[:max_wait_until_ready] + } + + opts[:keys_only] = true if data[:ssh_key] + opts[:keys] = Array(data[:ssh_key]) if data[:ssh_key] + opts[:auth_methods] = ["publickey"] if data[:ssh_key] + opts[:password] = data[:password] if data.key?(:password) + opts[:forward_agent] = data[:forward_agent] if data.key?(:forward_agent) + opts[:verbose] = data[:verbose].to_sym if data.key?(:verbose) + + opts + end + + # Creates a new SSH Connection instance and save it for potential future + # reuse. + # + # @param options [Hash] conneciton options + # @return [Ssh::Connection] an SSH Connection instance + # @api private + def create_new_connection(options, &block) + cleanup! + @connection_options = options + @connection = Kitchen::Transport::Ssh::Connection.new(options, &block) + end + + # Return the last saved SSH connection instance. + # + # @return [Ssh::Connection] an SSH Connection instance + # @api private + def reuse_connection + logger.debug("[SSH] reusing existing connection #{@connection}") + yield @connection if block_given? + @connection + end + end + end +end