|
1 #!/usr/bin/env ruby |
|
2 # vim: set nosta noet ts=4 sw=4: |
|
3 |
|
4 require 'shellwords' |
|
5 require 'symphony/task' unless defined?( Symphony::Task ) |
|
6 |
|
7 |
|
8 ### A base class for connecting to remote hosts, running arbitrary |
|
9 ### commands, and collecting output. |
|
10 ### |
|
11 ### This isn't designed to be used directly. To use this in your |
|
12 ### environment, you'll want to subclass it, add the behaviors |
|
13 ### that make sense for you, then super() back to the parent in the |
|
14 ### #work method. |
|
15 ### |
|
16 ### It expects the payload to contain the following keys: |
|
17 ### |
|
18 ### host: (required) The hostname to connect to |
|
19 ### command: (required) The command to run on the remote host |
|
20 ### port: (optional) The port to connect to (defaults to 22) |
|
21 ### opts: (optional) Explicit SSH client options |
|
22 ### user: (optional) The user to connect as (defaults to root) |
|
23 ### key: (optional) The path to an SSH private key |
|
24 ### |
|
25 ### |
|
26 ### Additionally, this class responds to the 'symphony_ssh' configurability |
|
27 ### key. Currently, you can set the 'path' argument, which is the |
|
28 ### full path to the local ssh binary (defaults to '/usr/bin/ssh') and |
|
29 ### override the default ssh user, key, and client opts. |
|
30 ### |
|
31 ### Textual output of the command is stored in the @output instance variable. |
|
32 ### |
|
33 ### |
|
34 ### require 'symphony' |
|
35 ### require 'symphony/tasks/ssh' |
|
36 ### |
|
37 ### class YourTask < Symphony::Task::SSH |
|
38 ### timeout 5 |
|
39 ### subscribe_to 'ssh.command' |
|
40 ### |
|
41 ### def work( payload, metadata ) |
|
42 ### status = super |
|
43 ### puts "Remote host said: %s" % [ @output ] |
|
44 ### return status.success? |
|
45 ### end |
|
46 ### end |
|
47 ### |
|
48 class Symphony::Task::SSH < Symphony::Task |
|
49 extend Configurability |
|
50 config_key :symphony_ssh |
|
51 |
|
52 # SSH default options. |
|
53 # |
|
54 CONFIG_DEFAULTS = { |
|
55 :path => '/usr/bin/ssh', |
|
56 :opts => [ |
|
57 '-e', 'none', |
|
58 '-T', |
|
59 '-x', |
|
60 '-q', |
|
61 '-o', 'CheckHostIP=no', |
|
62 '-o', 'BatchMode=yes', |
|
63 '-o', 'StrictHostKeyChecking=no' |
|
64 ], |
|
65 :user => 'root', |
|
66 :key => nil |
|
67 } |
|
68 |
|
69 # SSH "informative" stdout output that should be cleaned from the |
|
70 # command output. |
|
71 SSH_CLEANUP = %r/Warning: no access to tty|Thus no job control in this shell/ |
|
72 |
|
73 class << self |
|
74 # The full path to the ssh binary. |
|
75 attr_reader :path |
|
76 |
|
77 # A default set of ssh client options when connecting |
|
78 # to remote hosts. |
|
79 attr_reader :opts |
|
80 |
|
81 # The default user to use when connecting. If unset, 'root' is used. |
|
82 attr_reader :user |
|
83 |
|
84 # An absolute path to a password-free ssh private key. |
|
85 attr_reader :key |
|
86 end |
|
87 |
|
88 ### Configurability API. |
|
89 ### |
|
90 def self::configure( config=nil ) |
|
91 config = self.defaults.merge( config || {} ) |
|
92 @path = config.delete( :path ) |
|
93 @opts = config.delete( :opts ) |
|
94 @user = config.delete( :user ) |
|
95 @key = config.delete( :key ) |
|
96 super |
|
97 end |
|
98 |
|
99 |
|
100 ### Perform the ssh connection, passing the command to the pipe |
|
101 ### and retreiving any output from the remote end. |
|
102 ### |
|
103 def work( payload, metadata ) |
|
104 command = payload[ 'command' ] |
|
105 raise ArgumentError, "Missing required option 'command'" unless command |
|
106 raise ArgumentError, "Missing required option 'host'" unless payload[ 'host' ] |
|
107 |
|
108 exitcode = self.open_connection( payload, metadata ) do |reader, writer| |
|
109 self.log.debug "Writing command #{command}..." |
|
110 writer.puts( command ) |
|
111 self.log.debug " closing child's writer." |
|
112 writer.close |
|
113 self.log.debug " reading from child." |
|
114 reader.read |
|
115 end |
|
116 |
|
117 self.log.debug "SSH exited: %d" % [ exitcode ] |
|
118 return exitcode |
|
119 end |
|
120 |
|
121 |
|
122 ######### |
|
123 protected |
|
124 ######### |
|
125 |
|
126 ### Call ssh and yield the remote IO objects to the caller, |
|
127 ### cleaning up afterwards. |
|
128 ### |
|
129 def open_connection( payload, metadata=nil ) |
|
130 raise LocalJumpError, "no block given" unless block_given? |
|
131 @output = '' |
|
132 |
|
133 port = payload[ 'port' ] || 22 |
|
134 opts = payload[ 'opts' ] || Symphony::Task::SSH.opts |
|
135 user = payload[ 'user' ] || Symphony::Task::SSH.user |
|
136 key = payload[ 'key' ] || Symphony::Task::SSH.key |
|
137 |
|
138 cmd = [] |
|
139 cmd << Symphony::Task::SSH.path |
|
140 cmd += Symphony::Task::SSH.opts |
|
141 |
|
142 cmd << '-p' << port.to_s |
|
143 cmd << '-i' << key if key |
|
144 cmd << '-l' << user |
|
145 cmd << payload[ 'host' ] |
|
146 cmd.flatten! |
|
147 self.log.debug "Running SSH command with: %p" % [ Shellwords.shelljoin(cmd) ] |
|
148 |
|
149 parent_reader, child_writer = IO.pipe |
|
150 child_reader, parent_writer = IO.pipe |
|
151 |
|
152 pid = spawn( *cmd, :out => child_writer, :in => child_reader, :close_others => true ) |
|
153 child_writer.close |
|
154 child_reader.close |
|
155 |
|
156 self.log.debug "Yielding back to the run block." |
|
157 @output = yield( parent_reader, parent_writer ) |
|
158 @output = @output.split("\n").reject{|l| l =~ SSH_CLEANUP }.join |
|
159 self.log.debug " run block done." |
|
160 |
|
161 pid, status = Process.waitpid2( pid ) |
|
162 return status |
|
163 end |
|
164 |
|
165 end # Symphony::Task::SSH |
|
166 |