lib/symphony/tasks/ssh.rb
changeset 0 aef8f9f4a788
child 3 62196065e9ea
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/symphony/tasks/ssh.rb	Sat May 10 17:40:36 2014 -0700
@@ -0,0 +1,166 @@
+#!/usr/bin/env ruby
+# vim: set nosta noet ts=4 sw=4:
+
+require 'shellwords'
+require 'symphony/task' unless defined?( Symphony::Task )
+
+
+### A base class for connecting to remote hosts, running arbitrary
+### commands, and collecting output.
+###
+### This isn't designed to be used directly.  To use this in your
+### environment, you'll want to subclass it, add the behaviors
+### that make sense for you, then super() back to the parent in the
+### #work method.
+###
+### It expects the payload to contain the following keys:
+###
+###    host:    (required) The hostname to connect to
+###    command: (required) The command to run on the remote host
+###    port:    (optional) The port to connect to (defaults to 22)
+###    opts:    (optional) Explicit SSH client options
+###    user:    (optional) The user to connect as (defaults to root)
+###    key:     (optional) The path to an SSH private key
+###
+###
+### Additionally, this class responds to the 'symphony_ssh' configurability
+### key.  Currently, you can set the 'path' argument, which is the
+### full path to the local ssh binary (defaults to '/usr/bin/ssh') and
+### override the default ssh user, key, and client opts.
+###
+### Textual output of the command is stored in the @output instance variable.
+###
+###
+###    require 'symphony'
+###    require 'symphony/tasks/ssh'
+###
+###    class YourTask < Symphony::Task::SSH
+###        timeout 5
+###        subscribe_to 'ssh.command'
+###
+###        def work( payload, metadata )
+###            status = super
+###            puts "Remote host said: %s" % [ @output ]
+###            return status.success?
+###        end
+###    end
+###
+class Symphony::Task::SSH < Symphony::Task
+	extend Configurability
+	config_key :symphony_ssh
+
+	# SSH default options.
+	#
+	CONFIG_DEFAULTS = {
+		:path => '/usr/bin/ssh',
+		:opts => [
+			'-e', 'none',
+			'-T',
+			'-x',
+			'-q',
+			'-o', 'CheckHostIP=no',
+			'-o', 'BatchMode=yes',
+			'-o', 'StrictHostKeyChecking=no'
+		],
+		:user => 'root',
+		:key  => nil
+	}
+
+	# SSH "informative" stdout output that should be cleaned from the
+	# command output.
+	SSH_CLEANUP = %r/Warning: no access to tty|Thus no job control in this shell/
+
+	class << self
+		# The full path to the ssh binary.
+		attr_reader :path
+
+		# A default set of ssh client options when connecting
+		# to remote hosts.
+		attr_reader :opts
+
+		# The default user to use when connecting.  If unset, 'root' is used.
+		attr_reader :user
+
+		# An absolute path to a password-free ssh private key.
+		attr_reader :key
+	end
+
+	### Configurability API.
+	###
+	def self::configure( config=nil )
+		config = self.defaults.merge( config || {} )
+		@path  = config.delete( :path )
+		@opts  = config.delete( :opts )
+		@user  = config.delete( :user )
+		@key   = config.delete( :key )
+		super
+	end
+
+
+	### Perform the ssh connection, passing the command to the pipe
+	### and retreiving any output from the remote end.
+	###
+	def work( payload, metadata )
+		command = payload[ 'command' ]
+		raise ArgumentError, "Missing required option 'command'" unless command
+		raise ArgumentError, "Missing required option 'host'"    unless payload[ 'host' ]
+
+		exitcode = self.open_connection( payload, metadata ) do |reader, writer|
+			self.log.debug "Writing command #{command}..."
+			writer.puts( command )
+			self.log.debug "  closing child's writer."
+			writer.close
+			self.log.debug "  reading from child."
+			reader.read
+		end
+
+		self.log.debug "SSH exited: %d" % [ exitcode ]
+		return exitcode
+	end
+
+
+	#########
+	protected
+	#########
+
+	### Call ssh and yield the remote IO objects to the caller,
+	### cleaning up afterwards.
+	###
+	def open_connection( payload, metadata=nil )
+		raise LocalJumpError, "no block given" unless block_given?
+		@output = ''
+
+		port = payload[ 'port' ] || 22
+		opts = payload[ 'opts' ] || Symphony::Task::SSH.opts
+		user = payload[ 'user' ] || Symphony::Task::SSH.user
+		key  = payload[ 'key'  ] || Symphony::Task::SSH.key
+
+		cmd = []
+		cmd << Symphony::Task::SSH.path
+		cmd += Symphony::Task::SSH.opts
+
+		cmd << '-p' << port.to_s
+		cmd << '-i' << key if key
+		cmd << '-l' << user
+		cmd << payload[ 'host' ]
+		cmd.flatten!
+		self.log.debug "Running SSH command with: %p" % [ Shellwords.shelljoin(cmd) ]
+
+		parent_reader, child_writer = IO.pipe
+		child_reader, parent_writer = IO.pipe
+
+		pid = spawn( *cmd, :out => child_writer, :in => child_reader, :close_others => true )
+		child_writer.close
+		child_reader.close
+
+		self.log.debug "Yielding back to the run block."
+		@output = yield( parent_reader, parent_writer )
+		@output = @output.split("\n").reject{|l| l =~ SSH_CLEANUP }.join
+		self.log.debug "  run block done."
+
+		pid, status = Process.waitpid2( pid )
+		return status
+	end
+
+end # Symphony::Task::SSH
+