diff -r 000000000000 -r aef8f9f4a788 lib/symphony/tasks/sshscript.rb --- /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 +