lib/symphony/tasks/sshscript.rb
changeset 0 aef8f9f4a788
child 3 62196065e9ea
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/symphony/tasks/sshscript.rb	Sat May 10 17:40:36 2014 -0700
@@ -0,0 +1,183 @@
+#!/usr/bin/env ruby
+# vim: set nosta noet ts=4 sw=4:
+
+require 'net/ssh'
+require 'net/sftp'
+require 'tmpdir'
+require 'inversion'
+require 'symphony'
+require 'symphony/task'
+
+
+### A base class for connecting to a remote host, then uploading and
+### executing an Inversion templated script.
+###
+### 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
+###    template:   (required) A path to the Inversion templated script
+###    port:       (optional) The port to connect to (defaults to 22)
+###    user:       (optional) The user to connect as (defaults to root)
+###    key:        (optional) The path to an SSH private key
+###    attributes: (optional) Additional data to attach to the template
+###    nocleanup:  (optional) Leave the remote script after execution? (default to false)
+###
+###
+### Additionally, this class responds to the 'symphony_ssh' configurability
+### key.  Currently, you can override the default ssh user and private key.
+###
+### Textual output of the command is stored in the @output instance variable.
+###
+###
+###    require 'symphony'
+###    require 'symphony/tasks/sshscript'
+###
+###    class YourTask < Symphony::Task::SSHScript
+###        timeout 30
+###        subscribe_to 'ssh.script.*'
+###
+###        def work( payload, metadata )
+###            status = super
+###            puts "Remote script said: %s" % [ @output ]
+###            return status.success?
+###        end
+###    end
+###
+class Symphony::Task::SSHScript < Symphony::Task
+	extend Configurability
+	config_key :symphony_ssh
+
+	# Template config
+	#
+	TEMPLATE_OPTS = {
+		:ignore_unknown_tags => false,
+		:on_render_error     => :propagate,
+		:strip_tag_lines     => true
+	}
+
+	# The defaults to use when connecting via SSH
+	#
+	DEFAULT_SSH_OPTIONS = {
+		:auth_methods            => [ 'publickey' ],
+		:compression             => true,
+		:config                  => false,
+		:keys_only               => true,
+		:paranoid                => false,
+		:global_known_hosts_file => '/dev/null',
+		:user_known_hosts_file   => '/dev/null'
+	}
+
+	# SSH default options.
+	#
+	CONFIG_DEFAULTS = {
+		:user => 'root',
+		:key  => nil
+	}
+
+	class << self
+		# 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 || {} )
+		@user = config.delete( :user )
+		@key  = config.delete( :key )
+		super
+	end
+
+
+	### Perform the ssh connection, render the template, send it, and
+	### execute it.
+	###
+	def work( payload, metadata )
+		template   = payload[ 'template' ]
+		attributes = payload[ 'attributes' ] || {}
+		port       = payload[ 'port' ]    || 22
+		user       = payload[ 'user' ]    || Symphony::Task::SSHScript.user
+		key        = payload[ 'key'  ]    || Symphony::Task::SSHScript.key
+		nocleanup  = payload[ 'nocleanup' ]
+
+		raise ArgumentError, "Missing required option 'command'" unless template
+		raise ArgumentError, "Missing required option 'host'"    unless payload[ 'host' ]
+
+		remote_filename = self.make_remote_filename( template )
+		source = self.generate_script( template, attributes )
+
+		ssh_options = DEFAULT_SSH_OPTIONS.merge( :port => port, :keys => [key] )
+		ssh_options.merge!(
+			:logger  => Loggability[ Net::SSH ],
+			:verbose => :debug
+		) if payload[ 'debug' ]
+
+		Net::SSH.start( payload['host'], user, ssh_options ) do |conn|
+			self.log.debug "Uploading script (%d bytes) to %s:%s." %
+				[ source.bytesize, payload['host'], remote_filename ]
+			self.upload_script( conn, source, remote_filename )
+			self.log.debug "  done with the upload."
+
+			self.run_script( conn, remote_filename, nocleanup )
+			self.log.debug "Output was:\n#{@output}"
+		end
+
+		return true
+	end
+
+
+	#########
+	protected
+	#########
+
+	### Generate a unique filename for the script on the remote host,
+	### based on +template+ name.
+	###
+	def make_remote_filename( template )
+		basename = File.basename( template, File.extname(template) )
+		tmpname  = Dir::Tmpname.make_tmpname( basename, rand(10000) )
+
+		return "/tmp/#{tmpname}"
+	end
+
+
+	### Generate a script by loading the script +template+, populating it with
+	### +attributes+, and returning the rendered output.
+	###
+	def generate_script( template, attributes )
+		tmpl = Inversion::Template.load( template, TEMPLATE_OPTS )
+		tmpl.attributes.merge!( attributes )
+		tmpl.task = self
+
+		return tmpl.render
+	end
+
+
+	### Upload the templated +source+ via the ssh +conn+ to an
+	### executable file named +remote_filename+.
+	###
+	def upload_script( conn, source, remote_filename )
+		conn.sftp.file.open( remote_filename, "w", 0755 ) do |fh|
+			fh.print( source )
+		end
+	end
+
+
+	### Run the +remote_filename+ via the ssh +conn+.  The script
+	### will be deleted automatically unless +nocleanup+ is true.
+	###
+	def run_script( conn, remote_filename, nocleanup=false )
+		@output = conn.exec!( remote_filename )
+		conn.exec!( "rm #{remote_filename}" ) unless nocleanup
+	end
+
+end # Symphony::Task::SSHScript
+